Ansible tutorial: setting up user accounts, SSH and firewall with Ansible - feature image

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

Author image
by Sven WoltmannOctober 20, 2018

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
    - sshCode 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: yesCode 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 happy1Code 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: 0644Code 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: sudoCode 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 the sudo 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 command mkpasswd -m sha-512.

Task 3 – Create group sudo-nopasswd:

- name: Create "sudo-nopasswd" group
  group:
    name: sudo-nopasswdCode 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 == trueCode 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: yesCode 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: 0700Code 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: 0600Code 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:

  1. Installation and configuration of the sudo command,
  2. Creating the user and adding to/removing from groups,
  3. 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.ymlCode 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: yesCode 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: 0600Code 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 noCode 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 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: sshersCode 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 groupCode 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 serviceCode 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: restartedCode 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.ymlCode language: plaintext (plaintext)
Running the Bootstrap Ansible playbook
Running the Bootstrap Ansible playbook

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)
Login as user "sven"
Login as user "sven"

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: 0644Code 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: 3600Code 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
    - toolsCode language: YAML (yaml)

I restart the playbook:

ansible-playbook --extra-vars "target=happy1.happycoders.eu" bootstrap.ymlCode 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".

"Connection refused" when re-running the bootstrap playbook
"Connection refused" when re-running the bootstrap playbook

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
    - toolsCode 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 parameter hosts and omit the parameter --extra-vars "target=happy1.happycoders.eu" when running the playbook.
  • Using 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.
  • By specifying remote_user: sven, Ansible connects as user sven.
  • If 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.ymlCode language: plaintext (plaintext)
Running the Ansible playbook "happy1.yml"
Running the Ansible playbook "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.

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.ymlCode language: plaintext (plaintext)
User "sven" is added to the group "sudo-nopasswd
User "sven" is added to the group "sudo-nopasswd

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.ymlCode 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":

Searching for "firewall" in the Ansible Galaxy
Searching for "firewall" in the Ansible Galaxy

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.firewallCode language: Microtik RouterOS script (routeros)
Installation of the role "geerlingguy.firewall" from Ansible Galaxy
Installation of the role "geerlingguy.firewall" from Ansible Galaxy

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.firewallCode 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
  - 5930Code language: YAML (yaml)

I'm running the playbook:

ansible-playbook happy1.ymlCode language: plaintext (plaintext)
Firewall configuration with Ansible
Firewall configuration with Ansible

After running the playbook, I check whether the firewall settings are active:

ssh -p 5930 [email protected]
sudo iptables -L
sudo ip6tables -LCode language: plaintext (plaintext)
Displaying the firewall configuration with "iptables -L" and "ip6tables -L"
Displaying the firewall configuration with "iptables -L" and "ip6tables -L"

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.