
Ansible Tutorial: Setup of User Accounts, SSH, and Firewall with Ansible

Article Series: Ansible Tutorial
Part 1: Setting up a Server with Ansible
Part 2: User Accounts, SSH, and Firewall
Part 3: Docker, MySQL, and WordPress
Part 4: HAProxy + HTTPS from Let’s Encrypt
(Sign up for the HappyCoders Newsletter
to be immediately informed about new articles.)
In the first part of this tutorial series, I showed you how to install the operating system image on a Hetzner root server using Ansible. The second part of my Ansible tutorial is about "bootstrapping" the server. Under the term "bootstrapping", I summarize all actions that will be the same for all servers, regardless of which task a server will be used for. In detail, I will:
- apply patches,
- define the hostname,
- create a hosts file,
- create another user so I don't have to log in as
root
, - optimize the SSH configuration,
- create some bash aliases,
- install some helpful command line tools and
- configure the firewall.
The article code can be found in my GitLab repository at https://gitlab.com/SvenWoltmann/happycoders-tutorial-server-setup.
In addition to the article, there is also a (German-only) video tutorial: Setup of user accounts, SSH and firewall with Ansible
Bootstrap Playbook
First I create the file bootstrap.yml
in the project directory with the following content:
---
- hosts: "{{ target }}"
remote_user: root
roles:
- apt_upgrade
- hostnames
- users
- ssh
Code language: YAML (yaml)
The difference from the playbooks in the previous article is that I omit the line gather_facts: false
. Since the default value is true
, Ansible will collect the facts of the target system (e.g. its operating system version) before executing the first role. This is only possible if Python is already installed on the target system. This was the last step I did in the previous article. I will explain the listed roles step by step in the following sections.
Applying Patches
First of all, I want to bring the server up to date by installing all available patches. For this, I create the role apt_upgrade
, for which I define only a single task in the file roles/apt_upgrade/tasks/main.yml
:
---
- name: Update and upgrade apt packages
apt:
upgrade: yes
update_cache: yes
Code language: YAML (yaml)
I'm using the Ansible module apt
, where update_cache: yes
is the equivalent of apt update
and upgrade: yes
stands for apt upgrade
. This task is the equivalent to running apt update && apt-upgrade
on the command line.
Defining the Hostname
Now I want to make sure that the /etc/hostname
file contains the correct hostname. This should be done by the installimage
script, but it doesn't hurt to check it again at this point and correct it if necessary. Finally, this Ansible playbook should also run on servers of other hosters on which the hostname file may not be set during provisioning. I create the role hostnames
in the form of the file roles/hostnames/tasks/main.yml
with the following task definition:
---
- name: Set hostname
hostname:
name: "{{ inventory_hostname }}"
Code language: YAML (yaml)
The task uses the Ansible module hostname
to execute the command of the same name to change the hostname, and to write the hostname to the file /etc/hostname
, so that it is retained even if the server is restarted.
Creating the Hosts File
I also want to write all relevant server names to the /etc/hosts
file. At the moment, this is only one, but as soon as I have more than one server, it will be important. I copy the server's /etc/hosts
file to roles/hostnames/files/hosts
in my local project directory and just adjust the comments slightly so that the file has the following content:
# IPv4
127.0.0.1 localhost.localdomain localhost
46.4.99.9 happy1.happycoders.eu happy1
# IPv6
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
ff02::3 ip6-allhosts
2a01:4f8:140:9090::2 happy1.happycoders.eu happy1
Code language: plaintext (plaintext)
To copy the file to the server, I use the Ansible module copy
and insert the following entry into the roles/hostnames/tasks/main.yml
role file:
- name: Copy "hosts" file
copy:
src: hosts
dest: /etc/hosts
owner: root
group: root
mode: 0644
Code language: YAML (yaml)
src
specifies the source file relative to the files/
directory, while dest
is the destination path on the server. The remaining three parameters define the owner and the group of the file, as well as the bits for the file mode.
Creating a User
For security reasons, I don't want to connect to the server as a root user in the future, so I create a user sven
, and add it to the group sudo
. During the development phase, I don't want to have to enter my password again and again for sudo
. Therefore, I create an additional group, sudo-nopasswd
, add the user sven
and allow all users of this group to run sudo
without password. All this I do as usual with Ansible, i.e. I create the role users
and the file roles/users/tasks/main.yml
with the following tasks:
Task 1 – Install sudo
:
---
- name: Install sudo
apt:
name: sudo
Code language: YAML (yaml)
Task 2 – Create user sven
:
- name: Create "sven" user
user:
name: sven
shell: /bin/bash
uid: 1000
group: users
groups: sudo
append: true # --> user is not removed from any other group
password: $6$2i/vlnXuBj/m8A$y9xNcMAJQstggFMSWYbVeJStvvDpk4s4jScPv074Fb94jSfCbxyWSPz.tmMPQ6Qiy6mnpn07SQRTe6Sex4Pfi/
Code language: YAML (yaml)
I create the user with the Ansible module user
and use the following parameters:
- With
name
I define the username. - The user shell is set with
shell
. - I use
uid
to set a fixed user ID. - With
group
, I define the main group of the user. - With
groups
, I specify that the user should also be included in thesudo
group. - Important is
append: true
, otherwise the user would be removed from groups to which they might have been added in other playbooks. - With
password
, I specify my password in encrypted form. I generated it with the commandmkpasswd -m sha-512
.
Task 3 – Create group sudo-nopasswd
:
- name: Create "sudo-nopasswd" group
group:
name: sudo-nopasswd
Code language: YAML (yaml)
Here, I use the Ansible module group
; this creates the group specified under name
.
Task 4 – Add user sven
to the sudo-nopasswd
group:
- name: Add user "sven" to "sudo-nopasswd" group
user:
name: sven
groups: sudo-nopasswd
append: true # --> user is not removed from any other group
when: passwordless_sudo is defined and passwordless_sudo == true
Code language: YAML (yaml)
I'm using the user
module again, which I already explained above. With when
, I define that the group assignment should only be done if the variable passwordless_sudo
exists and is set to true
. So after the development phase, I can very easily reactivate the password query for sudo
by removing the variable or setting it to false
. In this case, task 4 is no longer executed, but the user is not removed from the group. I will do this in task 5.
Task 5 – Remove user sven
from the sudo-nopasswd
group:
- name: Remove user "sven" from "sudo-nopasswd" group
shell: /usr/sbin/delgroup sven sudo-nopasswd
when: not (passwordless_sudo is defined and passwordless_sudo == true)
ignore_errors: yes
Code language: YAML (yaml)
Unfortunately, this is not possible with the user
module, so I use the shell
module and the delgroup
Linux command. It is important to specify the line ignore_errors: yes
, otherwise the playbook will abort with an error message if the user is not in the group they should be removed from.
Task 6 – Specify that users of the sudo-nopasswd
group may run sudo
without a password:
- name: Add "sudo-nopasswd" group to "sudoers" file
lineinfile:
dest: /etc/sudoers
line: '%sudo-nopasswd ALL=(ALL:ALL) NOPASSWD:ALL'
regexp: '^%sudo-nopasswd'
Code language: YAML (yaml)
For this, I use the Ansible module lineinfile
with the following parameters:
- With
dest
, I specify the file to be edited. - With
line,
I pass the line which is to be entered into the file. - If there is another line that matches the regular expression passed with
regexp
(i.e. one that starts with%sudo-nopasswd
), it will be deleted.
In the last two lines, I have to put the strings in quotation marks according to the YAML Cookbook, because %
is a special character.
With the remaining four tasks, I copy the public keys for the users root
and sven
to the server. The public key for the root
user was already copied when the operating system was installed, but I want to be able to use this playbook to change the key if necessary or to install a key on a server that I did not set up using the installimage
script from Hetzner.
Task 7 – Create .ssh
directory for the root
user
- name: "Create root user's .ssh directory"
file:
path: /root/.ssh
state: directory
owner: root
group: root
mode: 0700
Code language: YAML (yaml)
Here, I use the Ansible module file
to create the directory /root/.ssh/
and – like the copy module – to set owner, group and mode.
Task 8 – Copy public keys for the root
user:
- name: "Copy root user's authorized_keys"
copy:
src: authorized_keys_root
dest: /root/.ssh/authorized_keys
owner: root
group: root
mode: 0600
Code language: YAML (yaml)
I have already explained the copy
module before. Tasks 9 and 10 create the directory /home/sven/.ssh/
and copy the source file authorized_keys_sven
to /home/sven/.ssh/authorized_keys
. The files authorized_keys_root
and authorized_keys_sven
are located in the source directory roles/users/files/
and contain the public keys of those persons who should have access to the server via SSH as users root
or sven
.
Distributing Tasks across Multiple Files
The file roles/users/tasks/main.yml
has become quite long and confusing, which I don't like. So I will extract separate individual blocks into other files and import them into the main.yml
with import_tasks
. I split the file into three logical blocks:
- Installation and configuration of the
sudo
command, - Creating the user and adding to/removing from groups,
- Copying the public keys.
The main.yml
now looks like this:
---
- import_tasks: setup_sudo.yml
- import_tasks: setup_users.yml
- import_tasks: upload_pubkeys.yml
Code language: YAML (yaml)
The three new files are located in the same directory as the main.yml
, i.e. in roles/users/tasks/
and look like this:
File roles/users/tasks/setup_sudo.yml
:
---
- name: Install sudo
apt:
name: sudo
- name: Create "sudo-nopasswd" group
group:
name: sudo-nopasswd
- name: Add "sudo-nopasswd" group to "sudoers" file
lineinfile:
dest: /etc/sudoers
line: '%sudo-nopasswd ALL=(ALL:ALL) NOPASSWD:ALL'
regexp: '^%sudo-nopasswd'
Code language: YAML (yaml)
File roles/users/tasks/setup_users.yml
:
---
- name: Create "sven" user
user:
name: sven
shell: /bin/bash
uid: 1000
group: users
groups: sudo
append: true # --> user is not removed from any other group
password: $6$2i/vlnXuBj/m8A$y9xNcMAJQstggFMSWYbVeJStvvDpk4s4jScPv074Fb94jSfCbxyWSPz.tmMPQ6Qiy6mnpn07SQRTe6Sex4Pfi/
- name: Add user "sven" to "sudo-nopasswd" group
user:
name: sven
groups: sudo-nopasswd
append: true # --> user is not removed from any other group
when: passwordless_sudo is defined and passwordless_sudo == true
- name: Remove user "sven" from "sudo-nopasswd" group
shell: /usr/sbin/delgroup sven sudo-nopasswd
when: not (passwordless_sudo is defined and passwordless_sudo == true)
ignore_errors: yes
Code language: YAML (yaml)
File roles/users/tasks/upload_pubkeys.yml
:
---
- name: "Create root user's .ssh directory"
file:
path: /root/.ssh
state: directory
owner: root
group: root
mode: 0700
- name: "Copy root user's authorized_keys"
copy:
src: authorized_keys_root
dest: /root/.ssh/authorized_keys
owner: root
group: root
mode: 0600
- name: "Create sven user's .ssh directory"
file:
path: /home/sven/.ssh
state: directory
owner: sven
group: users
mode: 0700
- name: "Copy sven user's authorized_keys"
copy:
src: authorized_keys_sven
dest: /home/sven/.ssh/authorized_keys
owner: sven
group: users
mode: 0600
Code language: YAML (yaml)
Optimizing the SSH Configuration
To better protect my server against attacks, I would like to make some changes to the standard SSH configuration. I'm not a security expert, so I won't go into the details here – there's plenty to read on the internet. I would like to change the following settings, among others:
- Only allow public-key authentication with state-of-the-art encryption algorithms;
- Disable connection as
root
user; - Connect via a port other than the default port 22 to become invisible to most malicious scans;
- Disable agent forwarding;
- Activate TCP forwarding only if required (via variable);
- Minimize login and connection timeouts.
For the configuration, I create the role ssh
and the SSH configuration file printed under this paragraph. This file contains the IP address of the server as a variable. To replace this variable when running the Ansible Playbook, the file must be placed in the templates/
directory and not in the files/
directory. The path of the file is therefore roles/ssh/templates/sshd_config.j2
. The extension ".j2" is not mandatory, but I use it to enable Jinja2 syntax highlighting in my IDE (in IntelliJ IDEA, the "Python" plugin must be installed for this). The file looks like this:
AcceptEnv LANG LC_*
AllowAgentForwarding no
{# using a boolean here and not a string, as a string might end up as 'True' if you set the variable to yes (without quote marks) #}
AllowTcpForwarding {{ 'yes' if allow_tcp_forwarding is defined and allow_tcp_forwarding == true else 'no' }}
AllowGroups sshers
ChallengeResponseAuthentication no
Ciphers [email protected],[email protected],aes256-ctr
ClientAliveCountMax 3
ClientAliveInterval 15
Compression delayed
GatewayPorts no
HostbasedAuthentication no
IgnoreRhosts yes
KexAlgorithms [email protected]
ListenAddress {{ ansible_default_ipv4.address }}:{{ ssh_port | default('22') }}
LoginGraceTime 10
LogLevel INFO
MaxAuthTries 3
PasswordAuthentication no
PermitEmptyPasswords no
PermitRootLogin no
Protocol 2
PubkeyAuthentication yes
StrictModes yes
Subsystem sftp /usr/lib/openssh/sftp-server
SyslogFacility AUTH
UsePAM yes
UsePrivilegeSeparation yes
X11Forwarding no
Code language: Properties (properties)
The explanation of all parameters would go beyond the scope of this article. I refer you to the sshd_config manual. I will limit myself to the two parameters where I used variables:
AllowTcpForwarding
: as mentioned in the introduction, I want to be able to enable this as needed and introduce the variableallow_tcp_forwarding
, which I set tofalse
in my host configuration file for now.ListenAddress
: I set this to the IP address of the server followed by the port number, which I can define in the variablessh_port
. If the variable is not defined, the default port is 22. I choose a random port – port 5930 and enter the following in my host configuration filehost_vars/happy1.happycoders.eu
:ssh_port: 5930
.
To copy the SSH configuration file and restart the SSH service, I create the task definition file roles/ssh/tasks/main.yml
, which contains the following three tasks:
Task 1 – Create group sshers
:
---
- name: Create "sshers" group
group:
name: sshers
Code language: YAML (yaml)
Due to the AllowGroups sshers
configuration entry in sshd_config
, only users of this group may connect via SSH.
Task 2 – Add user sven
to group sshers
:
- name: Add user "sven" to "sshers" group
user:
name: sven
groups: sshers
append: true # --> user is not removed from any other group
Code language: YAML (yaml)
Task 3 – Copy SSH configuration file:
- name: Configure SSH server
template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: 0644
notify:
- Restart SSH service
Code language: YAML (yaml)
Here I use the Ansible module template
, which copies a template file and replaces the variables it contains. The parameters are the same as in the previously used copy
module.
The last two lines are also new: These ensure that the handler Restart SSH service
is executed if the sshd_config
is changed (and only then). A handler is a task that is executed in response to a particular event. The handler is defined in the subdirectory handlers/
of the role, again in a main.yml
, i.e. in roles/ssh/handlers/main.yml
:
---
- name: Restart SSH service
service:
name: ssh
state: restarted
Code language: YAML (yaml)
I am using the Ansible module service
to restart the SSH service. It is important to know that the handler is not executed immediately after the task Configure SSH server
, but at the end of the playbook, together with all other handlers activated by notify
. This can also be preferred with the task meta: flush_handlers
, but this is not necessary at this point.
Executing the Playbook
The bootstrap playbook isn't quite finished yet (the configuration of the aliases and the installation of some tools are still missing), but now it's time to run it for the first time. I do this with the following command:
ansible-playbook --extra-vars "target=happy1.happycoders.eu" bootstrap.yml
Code language: plaintext (plaintext)
The playbook ran successfully to my satisfaction. The error when removing the user sven
from the group sudo-nopasswd
was expected and is being ignored correctly. Since the playbook has changed the SSH settings of the server, I have to connect to the server via the new port 5930 and as user sven
:
ssh -p 5930 [email protected]
Code language: plaintext (plaintext)
Creating Bash Aliases
What bothers me about the standard Debian installation is that different bash aliases are defined for the root
user and regular users. For the root
user, the file /root/.bashrc
contains the following aliases by default:
export LS_OPTIONS='--color=auto'
[...]
alias ls='ls $LS_OPTIONS'
alias ll='ls $LS_OPTIONS -l'
alias l='ls $LS_OPTIONS -lA'
Code language: Bash (bash)
For regular users, i.e. the user sven
, only a single alias is defined in the file /home/sven/.bashrc
– the rest is commented out and differs from those of the root
user:
alias ls='ls --color=auto'
#alias dir='dir --color=auto'
#alias vdir='vdir --color=auto'
#alias grep='grep --color=auto'
#alias fgrep='fgrep --color=auto'
#alias egrep='egrep --color=auto'
[...]
#alias ll='ls -l'
#alias la='ls -A'
#alias l='ls -CF'
Code language: Bash (bash)
I want to use the ls
aliases of the root
user for the sven
user and additionally activate the commented aliases for grep
, fgrep
, egrep
. But I don't want to overwrite /home/sven/.bashrc
because my playbook should also be compatible with future Debian releases, which might install another .bashrc
file. Instead, I will write the aliases to /home/sven/.bash_aliases
. For this, I create a role aliases
and create the file roles/aliases/files/.bash_aliases
with the following content:
# enable color support of ls and also add handy aliases
if [ -x /usr/bin/dircolors ]; then
test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias fgrep='fgrep --color=auto'
alias egrep='egrep --color=auto'
fi
# ls aliases from root user
alias l='ls -lA'
alias ll='ls -l'
Code language: Bash (bash)
The file is copied to the server with the following task definition in the file roles/aliases/tasks/main.yml
:
---
- name: Copy sven's .bash_aliases
copy:
src: .bash_aliases
dest: /home/sven/.bash_aliases
owner: sven
group: users
mode: 0644
Code language: YAML (yaml)
Installing Tools
Finally, I install some tools that I will need on all servers. I create a role tools
and define which packages should be installed in the tasks file roles/tools/tasks/main.yml
:
---
- name: Install Midnight Commander, NTP daemon
apt:
name:
- mc
- ntp
cache_valid_time: 3600
Code language: YAML (yaml)
I already described the module apt
above. New is the parameter cache_valid_time: 3600
. I use this parameter to specify that the package list should be updated with apt update
before installation if it is older than 3,600 seconds (1 hour).
Running the Playbook Again
I add the two new roles, aliases
and tools
to the bootstrap playbook bootstrap.yml
, which now looks like this:
---
- hosts: "{{ target }}"
remote_user: root
roles:
- apt_upgrade
- hostnames
- users
- ssh
- aliases
- tools
Code language: YAML (yaml)
I restart the playbook:
ansible-playbook --extra-vars "target=happy1.happycoders.eu" bootstrap.yml
Code language: plaintext (plaintext)
But this time, Ansible aborts with the following error message: "Failed to connect to the host via ssh: ssh: connect to host happy1.happycoders.eu port 22: Connection refused".
The reason is quite simple: The bootstrap playbook tries to connect as root
user to the standard SSH port 22. Due to the hardened SSH configuration, however, the connection must be made as user sven
on port 5930.
Happy Ansible Playbook
If I want to change the configuration, I can't do that via the bootstrap playbook. Not only can it no longer connect to the server with the previous configuration – it also updates all packages, which I don't want to let happen uncontrolled in production mode whenever I change something in the server configuration.
The solution is: I create a new playbook, happy1.yml
, which connects as user sven
on port 5930 and executes all roles that the bootstrap playbook would execute – except the role apt_upgrade
(for above mentioned reason):
---
- hosts: happy1.happycoders.eu
vars:
ansible_port: "{{ ssh_port | default('22') }}"
remote_user: sven
become: yes
roles:
- hostnames
- users
- ssh
- aliases
- tools
Code language: YAML (yaml)
The following distinguishes this playbook from the bootstrap playbook:
- This playbook is not a generic one, but a concrete one for my server
happy1.happycoders.eu
. So I can specify it directly via the parameterhosts
and omit the parameter--extra-vars "target=happy1.happycoders.eu"
when running the playbook. - Using
vars
I, set the variableansible_port
to the SSH port I set in the host configuration file in the variablessh_port
, so Ansible connects to the server via this port. - By specifying
remote_user: sven
, Ansible connects as usersven
. - If
become: yes
is specified, Ansible executes all commands on the server asroot
user.
When running this playbook, I now have to add the -K
parameter so that Ansible asks me for the sudo
password:
ansible-playbook -K happy1.yml
Code language: plaintext (plaintext)
You can see that the playbook runs completely and reports "ok" on almost all tasks, which means that no changes have been made. Only for the two new tasks does Ansible report "changed", which means that the Bash aliases and tools have been successfully installed.
sudo without Password
As you may remember, in the bootstrap playbook in the users
role, I created the option of not having to enter the password for sudo
during the development phase. I want to activate this now. Therefore, I have to add the line passwordless_sudo: true
to the host configuration file host_vars/happy1.happycoders.eu
and run the playbook again:
ansible-playbook -K happy1.yml
Code language: plaintext (plaintext)
For the task Add user "sven" to "sudo-nopasswd" group,
Ansible reports "changed", which means that the task was executed, i.e. the user was added to the group. And the task Remove user "sven" from "sudo-nopasswd" group
is acknowledged by Ansible with "skipping", i.e. this step was skipped, so the user was not removed from the group again. If I now set passwordless_sudo
to false
and run the playbook again, the first task would be skipped and the second would be executed.
In the future executions of the playbook, the parameter -K
can now be omitted (until I set passwordless_sudo
to false
again):
ansible-playbook happy1.yml
Code language: plaintext (plaintext)
Firewall from the Ansible Galaxy
The last step in this article is to install a firewall that only allows requests on ports 80, 443 and 5930. Since this could be quite complex, I'll see if someone else has already taken the trouble to write a firewall role. The Ansible developer community releases roles on Ansible Galaxy. There, I search for the keyword "firewall":
I also search Google and find some GitHub projects, of which only the firewall role of geerlingguy, which I found in the Ansible Galaxy, having more than 200 stars is relevant; all other Ansible firewall projects have less than five stars.
The developer writes about his role: "This firewall aims for simplicity over complexity", which is confirmed later on: I can unlock individual ports, and if I want to get more into detail, I can add arbitrarily complex iptables
commands.
The role is simply installed on the local computer as follows:
ansible-galaxy install geerlingguy.firewall
Code language: Microtik RouterOS script (routeros)
I add the role geerlingguy.firewall
to the playbook happy1.yml
:
---
- hosts: happy1.happycoders.eu
vars:
ansible_port: "{{ ssh_port | default('22') }}"
remote_user: sven
become: yes
roles:
- hostnames
- users
- ssh
- aliases
- tools
- geerlingguy.firewall
Code language: YAML (yaml)
The role is configured using variables that I add to the host configuration file host_vars/happy1.happycoders.eu
. All I have to do is specify the three ports I want opened, the rest is done by the role. I add the following to the host configuration file:
firewall_allowed_tcp_ports:
- 80
- 443
- 5930
Code language: YAML (yaml)
I'm running the playbook:
ansible-playbook happy1.yml
Code language: plaintext (plaintext)
After running the playbook, I check whether the firewall settings are active:
ssh -p 5930 [email protected]
sudo iptables -L
sudo ip6tables -L
Code language: plaintext (plaintext)
You see: The firewall has been configured as desired.
Summary and Outlook
In this part of the series, I have shown you the "bootstrapping" of the server, i.e. all those installations and configurations I will need for future servers with possibly different application areas.
In the third part of the series, I will extend the happy1
playbook and show you how to install Docker, MySQL and WordPress with Ansible.
If you liked this second part of the series, feel free to leave me a comment or share the article using one of the share buttons at the end.
Do you want to be informed when new articles are published on HappyCoders.eu? Then click here to sign up for the HappyCoders.eu newsletter.