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:
root
,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
The article series is divided into the following four parts:
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
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.
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
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.
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 }}"
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.
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
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
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.
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
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/
I create the user with the Ansible module user
and use the following parameters:
name
I define the username.shell
.uid
to set a fixed user ID.group
, I define the main group of the user.groups
, I specify that the user should also be included in the sudo
group.append: true
, otherwise the user would be removed from groups to which they might have been added in other playbooks.password
, I specify my password in encrypted form. I generated it with the command mkpasswd -m sha-512
.Task 3 – Create group sudo-nopasswd
:
- name: Create "sudo-nopasswd" group
group:
name: sudo-nopasswd
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
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
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'
For this, I use the Ansible module lineinfile
with the following parameters:
dest
, I specify the file to be edited.line,
I pass the line which is to be entered into the file.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
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
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
.
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:
sudo
command,The main.yml
now looks like this:
---
- import_tasks: setup_sudo.yml
- import_tasks: setup_users.yml
- import_tasks: upload_pubkeys.yml
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'
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
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
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:
root
user;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
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 variable allow_tcp_forwarding
, which I set to false
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 variable ssh_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 file host_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
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
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
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
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.
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
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]
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'
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'
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'
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
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
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).
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
I restart the playbook:
ansible-playbook --extra-vars "target=happy1.happycoders.eu" bootstrap.yml
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.
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
The following distinguishes this playbook from the bootstrap playbook:
happy1.happycoders.eu
. So I can specify it directly via the parameter hosts
and omit the parameter --extra-vars "target=happy1.happycoders.eu"
when running the playbook.vars
I, set the variable ansible_port
to the SSH port I set in the host configuration file in the variable ssh_port
, so Ansible connects to the server via this port.remote_user: sven
, Ansible connects as user sven
.become: yes
is specified, Ansible executes all commands on the server as root
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
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.
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
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
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
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
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
I'm running the playbook:
ansible-playbook happy1.yml
After running the playbook, I check whether the firewall settings are active:
ssh -p 5930 [email protected]
sudo iptables -L
sudo ip6tables -L
You see: The firewall has been configured as desired.
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.
I thank you for reading this article and would be happy if you left a comment to tell me how you liked the tutorial, what I could have done better and what topics you are interested in. Thanks a lot and happy coding!
I would also be very happy if you would share the article via one of the following share buttons.