Ansible - automating infrastructure and applications

Setting up VMs
Creating the git repository
Installing ansible with basic configuration and testing
Using YAML playbooks
Conditional statement when
Consolidating the playbook
Using groups and tags
Activating and enabling systemd services
Troubleshooting ansible commands and results

Setting up the virtual machines

Ansible does not need an agent installed on the target machines; it needs ssh acccess to the VMs.
For this tutorial, we will need a few VMs to test and ssh keys for a local user on the machines and for ansible acccess.
You can use VirtualBox for example, to create one VM and link clone it, so that you can save disk space. You should also use bridged adatper for networking and assign static IP addresses in your router’s network settings. This way, the router will assign the IPs to the VMs and they will also access the internet. I have created a VM, debian-1, IP 192.168.1.105, and created the same user and password from my main machine; this is to quickly ssh into the VM without entering additional credentials:

george@dom:~$ ssh 192.168.1.105
The authenticity of host '192.168.1.105 (192.168.1.105)' can't be established.
ED25519 key fingerprint is SHA256:T+nOIfAkwSjDcQrq9+NF1sC4uV+gNBeTw5aQPI0KuyE.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '192.168.1.105' (ED25519) to the list of known hosts.
george@192.168.1.105's password:
Linux debian-1 6.1.0-31-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.128-1 (2025-02-07) x86_64
[..]
Last login: Sun Feb  9 16:46:07 2025
george@debian-1:~$ uname -a
Linux debian-1 6.1.0-31-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.128-1 (2025-02-07) x86_64 GNU/Linux

Notice the IP address of the new VM I have connected to via ssh with the credentials used on my main PC:

george@debian-1:~$ ip a s
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    inet 192.168.1.105/24 brd 192.168.1.255 scope global enp0s3

This will be useful if you have many VMs and want to use your existing credentials. The next step is to generate a ssh keypair to use when connecting to the VM; this will be safer, as we will also add a passphrase to the keypair. Use ssh-keygen:

george@dom:~$ ssh-keygen -t ed25519 -C "george"
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/george/.ssh/id_ed25519): /home/george/.ssh/george
Enter passphrase for "/home/george/.ssh/george" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/george/.ssh/george
Your public key has been saved in /home/george/.ssh/george.pub
The key fingerprint is:
SHA256:JxkHyyJ2CK6NN7pWkZevqC/8UU/R1pM12kKNWJVUmlE george
The key's randomart image is:
+--[ED25519 256]--+
|  .     . oo=*+E |
| . . . ..+o.=o=  |
|  . = +.+o.* +   |
| + + = .o+  o    |
|o + o...S .      |
| o o. o. o       |
|o ... ..         |
| = ...           |
|o.=o             |
+----[SHA256]-----+

To see the public key, which we will be adding to each VM:

$ cat .ssh/george.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMuEa83UtyRpHEPFmz+3aIwG5Bhh4CcCO/EY4PaOY5ZM george

We use ssh-copy-id to add the key to the VM with IP 192.168.1.105.

$ ssh-copy-id -i ~/.ssh/george.pub 192.168.1.105
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/george/.ssh/george.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
george@192.168.1.105's password:

Number of key(s) added: 1

Now try logging into the machine, with: "ssh -i /home/george/.ssh/george '192.168.1.105'"
and check to make sure that only the key(s) you wanted were added.

We can use ssh -i to connect to the VM with the ssh keypair corresponding to the user george:

$ ssh -i /home/george/.ssh/george 192.168.1.105
Enter passphrase for key '/home/george/.ssh/george':
Linux debian-1 6.1.0-31-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.128-1 (2025-02-07) x86_64
Last login: Sun Feb  9 17:12:13 2025 from 192.168.1.101
george@debian-1:~$

Now let’s disable password authentication, which means we can only connect to the VM with our ssh keypair. This makes connecting to the VM more secure, as passwords are weaker than keypairs. Run the below

sudo nano /etc/ssh/sshd_config

Search for PasswordAuthentication and change it to no.

# To disable tunneled clear text passwords, change to no here!
PasswordAuthentication no

Reload the sshd service

sudo systemctl reload sshd.service

Now we cannot login to the VM without specifying an ssh key.

$ ssh 192.168.1.105
george@192.168.1.105: Permission denied (publickey).

Let’s create another ssh keypair, this time for use with ansible. We will not add a passphrase, as we would have to enter it manually each time we run ansible.

$ ssh-keygen -t ed25519 -C "ansible"
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/george/.ssh/id_ed25519): /home/george/.ssh/ansible
Enter passphrase for "/home/george/.ssh/ansible" (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/george/.ssh/ansible
Your public key has been saved in /home/george/.ssh/ansible.pub
The key fingerprint is:
SHA256:GGVWC1G0mG1D4fQqG/jCWSuszbLiNuK7a3tmLCksVTg ansible

You would use the command below, if you were able to log into the VM via ssh with a passsword. But we have disabled password authentication, as it is less secure.

$ ssh-copy-id -i ~/.ssh/ansible.pub

Use the below to copy the ansible ssh keypair to the VM, by authenticating with your account’s keypair (george).

$ ssh-copy-id -f -i ~/.ssh/ansible.pub -o 'IdentityFile ~/.ssh/george' 192.168.1.105

On the VM, make sure the ansible key has been added:

$ cat .ssh/authorized_keys
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMuEa83UtyRpHEPFmz+3aIwG5Bhh4CcCO/EY4PaOY5ZM george
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFpOjYNDtxA9ZCXRVFeRkIexuy17awVvxz/GvyUEyK04 ansible

To test the ssh connectio to the VM with the ansible key; we did not add a passhprase, as it would have to be manually typed and would break the automation workflow:

$ ssh -i /home/george/.ssh/ansible 192.168.1.106
Linux debian-2 6.1.0-31-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.128-1 (2025-02-07) x86_64
[..]
Last login: Sun Feb  9 20:57:47 2025 from 192.168.1.101

Creating the git repository

In this section we will be creating a local git repository, which we will synchronise with gitlab.com. You could also you another online git hosting platform, such as github. We will create a folder named ansible and use git init:

george@dom:~$ mkdir ansible
george@dom:~$ cd ansible/
george@dom:~/ansible$ git init
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint:   git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint:   git branch -m <name>
Initialized empty Git repository in /home/george/ansible/.git/

In this folder we can create a readme file, to document the repo. george@dom:/ansible$ touch readme.md george@dom:/ansible$ nano readme.md

You need to configure your user name and email to sync with gitlab.
george@dom:~/ansible$ git config --global user.name "user name"
george@dom:~/ansible$ git config --global user.email "your email address"

To store the credentials, use the below, you’ll not have to type the user and password every time you want to sync the repo.

git config --global credential.helper store

The git status command shows what files need to be synched with the gitlab repo.

george@dom:~/ansible$ git status
On branch master

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        readme.md

We need to add the git repo to gitlab.

git remote add origin https://gitlab.com./user/ansible

Let’s sync it with gitlab: For the password, you need an access token that from your Gitlab profile -> Access tokens -> Add new token (tick api):

$ git push -u origin master
Username for 'https://gitlab.com.': user
Password for 'https://user@gitlab.com.':
warning: redirecting to https://gitlab.com./user/ansible.git/
Enumerating objects: 16, done.
Counting objects: 100% (16/16), done.
Delta compression using up to 12 threads
Compressing objects: 100% (14/14), done.
Writing objects: 100% (16/16), 4.36 KiB | 1.45 MiB/s, done.

To sync the repo, we need to first add the newly created readme.md file, by using git add .
You can also use git add readme.md for individual files, but the . will add all modified files in all the project folders. Use git commit -m to add a message to the commit.

george@dom:~/ansible$ git add .
george@dom:~/ansible$ git commit -m "Updated readme file, modified contents."
[master be0dab8] Updated readme file, modified contents.
 1 file changed, 1 insertion(+), 1 deletion(-)

Finally, use git push origin master to push the changes upstream the gitlab repo.

george@dom:~/ansible$ git push origin master
warning: redirecting to https://gitlab.com/user/ansible.git/
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 283 bytes | 283.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To https://gitlab.com/user/ansible
  b7a50e8..be0dab8  master -> master

Installing ansible with basic configuration and testing

Create an inventory file, where you add the IPs or hostnames of the VMs you’d like to manage via ansible:

192.168.1.105
192.168.1.106
192.168.1.107

Add the file to the git repo and sync it via git add ., git commit -m and git push origin master. Install ansible on your main PC, in Debian:

sudo apt update
sudo apt install ansible

Let’s see if the software package was installed:

george@dom:~/ansible$ ansible --version
ansible [core 2.18.1]
  config file = None
  configured module search path = ['/home/george/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = /home/george/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/bin/ansible
  python version = 3.13.2 (main, Feb  5 2025, 01:23:35) [GCC 14.2.0] (/usr/bin/python3)
  jinja version = 3.1.5
  libyaml = True

To test if ansible is working, type the command below; it tests connection to the VMs in the inventory file by doing a ping:

$ ansible all --key-file ~/.ssh/ansible -i inventory -m ping

[WARNING]: Platform linux on host 192.168.1.105 is using the discovered Python interpreter at /usr/bin/python3.11, but future installation of another Python interpreter could change the
meaning of that path. See https://docs.ansible.com/ansible-core/2.18/reference_appendices/interpreter_discovery.html for more information.
192.168.1.105 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.11"
    },
    "changed": false,
    "ping": "pong"
}

To simplify, we can create the ansible.cfg configuration file, and add the ssh key and inventory file:

[defaults][defaults]
inventory = inventory
private_key_file = ~/.ssh/ansible

We can run the command

$ ansible all -m ping

192.168.1.107 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.11"
    },
    "changed": false,
    "ping": "pong"
}

If you want to list all the hosts for which ansible will execute commands, use:

$ ansible all --list-hosts

  hosts (3):
    192.168.1.105
    192.168.1.106
    192.168.1.107

To get detailed information for all the VMs in the ansible inventory, use the gather_facts option. You can also use this option and IP addresses separated by comma to limit the search --limit 192.168.1.105,192.168.1.106:

$ ansible all -m gather_facts

192.168.1.106 | SUCCESS => {
    "ansible_facts": {
        "ansible_all_ipv4_addresses": [
            "192.168.1.106"
        ],
        "ansible_all_ipv6_addresses": [
            "fe80::a00:27ff:fe14:5505"
        ],
        "ansible_apparmor": {
            "status": "enabled"
        },
        "ansible_architecture": "x86_64",
        "ansible_bios_date": "12/01/2006",
        "ansible_bios_vendor": "innotek GmbH",
        "ansible_bios_version": "VirtualBox",

To install software, we use apt for Debian and the options --become --ask-become-pass for running the apt command as sudo on the target machines. On these VMs we have the same username and password as on our main machine. As can be seen, the package vim-nox was installed.

$ ansible all -m apt -a name=vim-nox --become --ask-become-pass
BECOME password:
192.168.1.107 | CHANGED => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.11"
    },
    "cache_update_time": 1739118178,
    "cache_updated": false,
    "changed": true,
    "stderr": "",
    "stderr_lines": [],
    "stdout": "Reading package lists...\nBuilding dependency tree...
    [..]
        "stdout_lines": [
        "Reading package lists...",
        "Building dependency tree...",
        "Reading state information...",
        "The following additional packages will be installed:",
        "  fonts-lato javascript-common libjs-jquery liblua5.2-0 libncurses6",
        "  libpython3.11 libruby libruby3.1 libsodium23 libtcl8.6 libyaml-0-2 rake ruby",
        "  ruby-net-telnet ruby-rubygems ruby-sdbm ruby-webrick ruby-xmlrpc ruby3.1",
        "  rubygems-integration vim-runtime zip",
        [..]
        "Setting up vim-nox (2:9.0.1378-2) ...",

We can also update the apt cache of available applications, to make sure we get the latest ones.

ansible all -m apt -a update_cache=true --become --ask-become-pass

If we look at /var/log/apt/history.log, we can see the command the ansible ran to install the vim-nox package, via the user george.

Start-Date: 2025-02-10  15:53:00
Commandline: /usr/bin/apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold install vim-nox=2:9.0.1378-2
Requested-By: george (1000)
Install: ruby-sdbm:amd64 (1.0.0-5+b1, automatic), zip:amd64 (3.0-13, automatic), fonts-lato:amd64 (2.0-2.1, automatic), liblua5.2-0:amd64 (5.2.4-3, automatic), libtcl8.6:amd64 (8.6.13+dfsg-2, automatic), libruby:amd64 (1:3.1, automatic), ruby-net-telnet:amd64 (0.2.0-1, automatic), rubygems-integration:amd64 (1.18, automatic), libyaml-0-2:amd64 (0.2.5-1, automatic), libruby3.1:amd64 (3.1.2-7+deb12u1, automatic), rake:amd64 (13.0.6-3, automatic), libsodium23:amd64 (1.0.18-1, automatic), vim-nox:amd64 (2:9.0.1378-2), libpython3.11:amd64 (3.11.2-6+deb12u5, automatic), ruby:amd64 (1:3.1, automatic), vim-runtime:amd64 (2:9.0.1378-2, automatic), ruby3.1:amd64 (3.1.2-7+deb12u1, automatic), libjs-jquery:amd64 (3.6.1+dfsg+~3.5.14-1, automatic), ruby-rubygems:amd64 (3.3.15-2, automatic), javascript-common:amd64 (11+nmu1, automatic), ruby-xmlrpc:amd64 (0.3.2-2, automatic), libncurses6:amd64 (6.4-4, automatic), ruby-webrick:amd64 (1.8.1-1, automatic)
End-Date: 2025-02-10  15:53:41

Using YAML playbooks

Let’s create the file install_apache.yml to install apache web server on all the hosts. Use spaces and not tabs.

---

- hosts: all
  become: true
  tasks:
    - name: Install apache package
      apt:
        name: apache2

To to a dry run (does not install package), use the --check parameter. You may notice changed=1 at the end of the log, meaning that the apache package would be installed.

$ ansible-playbook install_apache.yml  --check --ask-become-pass
BECOME password:

PLAY [all] ***********************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************
ok: [192.168.1.106]
ok: [192.168.1.107]
ok: [192.168.1.105]

TASK [Install apache package] ****************************************************************************************************************************************************************
changed: [192.168.1.107]
changed: [192.168.1.106]
changed: [192.168.1.105]

PLAY RECAP ***********************************************************************************************************************************************************************************
192.168.1.105              : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.106              : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.107              : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

We can also add options to update the package cache before installing packages - use update_cache. We also will install the libapache2-mod-php package, and the state: latest option ensures we have the newest version available in the repositories. The new yaml file is install_apache-2.yml.

---

- hosts: all
  become: true
  tasks:
    - name: Update repository cache
      apt:
        update_cache: yes
    - name: Install apache package
      apt:
        name: apache2
    - name: Add support for apache
      apt:
        name: libapache2-mod-php
        state: latest

Doing a dry run shows all the tasks in the playbook have completed successfully.

$ ansible-playbook install_apache-2.yml  --check --ask-become-pass
BECOME password:

PLAY [all] ***********************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************
ok: [192.168.1.105]
ok: [192.168.1.107]
ok: [192.168.1.106]

TASK [Update repository cache] ***************************************************************************************************************************************************************
changed: [192.168.1.105]
changed: [192.168.1.106]
changed: [192.168.1.107]

TASK [Install apache package] ****************************************************************************************************************************************************************
changed: [192.168.1.107]
changed: [192.168.1.106]
changed: [192.168.1.105]

TASK [Add support for apache] ****************************************************************************************************************************************************************
changed: [192.168.1.106]
changed: [192.168.1.105]
changed: [192.168.1.107]

PLAY RECAP ***********************************************************************************************************************************************************************************
192.168.1.105              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.106              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.107              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

If we want to remove packages that were installed on a VM, we can use the state: absent option in each package section. The file is remove_apache.yml.

---

- hosts: all
  become: true
  tasks:
    - name: Remove apache package
      apt:
        name: apache2
        state: absent
    - name: Remove support for apache
      apt:
        name: libapache2-mod-php
        state: absent

Conditional statement when

I have added a new centos server, IP 192.168.1.108, a Redhat VM, to demonstrate the use of the when statement. Installing apache will fail on this VM, because the apt package manager is only present on the Debian VMs.

$ ansible-playbook install_apache-2.yml  --check --ask-become-pass
BECOME password:

PLAY [all] ***********************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************
ok: [192.168.1.107]
ok: [192.168.1.105]
ok: [192.168.1.106]
ok: [192.168.1.108]

TASK [Update repository cache] ***************************************************************************************************************************************************************
fatal: [192.168.1.108]: FAILED! => {"changed": false, "msg": "python3-apt must be installed to use check mode. If run normally this module can auto-install it."}
changed: [192.168.1.105]
changed: [192.168.1.106]
changed: [192.168.1.107]

TASK [Install apache package] ****************************************************************************************************************************************************************
changed: [192.168.1.107]
changed: [192.168.1.106]
changed: [192.168.1.105]

TASK [Add support for apache] ****************************************************************************************************************************************************************
changed: [192.168.1.107]
changed: [192.168.1.105]
changed: [192.168.1.106]

PLAY RECAP ***********************************************************************************************************************************************************************************
192.168.1.105              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.106              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.107              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.108              : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

Using the ansible_distribution condition will install the apache package and update the cache only on the Debian VMs. The install_apache-3.yml file:

---

- hosts: all
  become: true
  tasks:
    - name: Update repository cache
      apt:
        update_cache: yes
      when: ansible_distribution == "Debian"
    - name: Install apache package
      apt:
        name: apache2
      when: ansible_distribution == "Debian"
    - name: Add support for apache
      apt:
        name: libapache2-mod-php
        state: latest
      when: ansible_distribution == "Debian"

Let’s run the command again. The skipping and skipped=3 statuses on the Redhat VM means that the playbook was not run on this machine and no errors are now reported.

$ ansible-playbook install_apache-3.yml  --check --ask-become-pass
BECOME password:

PLAY [all] ***********************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************
ok: [192.168.1.105]
ok: [192.168.1.106]
ok: [192.168.1.107]
ok: [192.168.1.108]

TASK [Update repository cache] ***************************************************************************************************************************************************************
skipping: [192.168.1.108]
changed: [192.168.1.106]
changed: [192.168.1.107]
changed: [192.168.1.105]

TASK [Install apache package] ****************************************************************************************************************************************************************
skipping: [192.168.1.108]
changed: [192.168.1.106]
changed: [192.168.1.107]
changed: [192.168.1.105]

TASK [Add support for apache] ****************************************************************************************************************************************************************
skipping: [192.168.1.108]
changed: [192.168.1.107]
changed: [192.168.1.106]
changed: [192.168.1.105]

PLAY RECAP ***********************************************************************************************************************************************************************************
192.168.1.105              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.106              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.107              : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
192.168.1.108              : ok=1    changed=0    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0

To tailor this further, we can add checks for both Debian and Redhat, and change the name of the tasks accordingly - file install_apache-4.yml:

---

- hosts: all
  become: true
  tasks:

    - name: Update repository cache - Debian
      apt:
        update_cache: yes
      when: ansible_distribution == "Debian"
    - name: Install apache package - Debian
      apt:
        name: apache2
      when: ansible_distribution == "Debian"
    - name: Add support for apache - Debian
      apt:
        name: libapache2-mod-php
        state: latest
      when: ansible_distribution == "Debian"
# This is for AlmaLinux, a Redhat compatible linux
    - name: Update repository cache - Redhat
      dnf:
        update_cache: yes
      when: ansible_distribution == "AlmaLinux"
    - name: Install apache package - Redhat
      dnf:
        name: httpd
      when: ansible_distribution == "AlmaLinux"
    - name: Add support for apache - Redhat
      dnf:
        name: php
        state: latest
      when: ansible_distribution == "AlmaLinux"

It is now much clearer what is being done and on which VMs:

$ ansible-playbook install_apache-4.yml  --check --ask-become-pass
BECOME password:

PLAY [all] ***********************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************
ok: [192.168.1.106]
ok: [192.168.1.105]
ok: [192.168.1.107]
ok: [192.168.1.108]

TASK [Update repository cache - Debian] ******************************************************************************************************************************************************
skipping: [192.168.1.108]
changed: [192.168.1.105]
changed: [192.168.1.107]
changed: [192.168.1.106]

TASK [Install apache package - Debian] *******************************************************************************************************************************************************
skipping: [192.168.1.108]
changed: [192.168.1.106]
changed: [192.168.1.105]
changed: [192.168.1.107]

TASK [Add support for apache - Debian] *******************************************************************************************************************************************************
skipping: [192.168.1.108]
changed: [192.168.1.106]
changed: [192.168.1.107]
changed: [192.168.1.105]

TASK [Update repository cache - Redhat] ******************************************************************************************************************************************************
skipping: [192.168.1.105]
skipping: [192.168.1.106]
skipping: [192.168.1.107]
ok: [192.168.1.108]

TASK [Install apache package - Redhat] *******************************************************************************************************************************************************
skipping: [192.168.1.105]
skipping: [192.168.1.106]
skipping: [192.168.1.107]
changed: [192.168.1.108]

TASK [Add support for apache - Redhat] *******************************************************************************************************************************************************
skipping: [192.168.1.105]
skipping: [192.168.1.106]
skipping: [192.168.1.107]
changed: [192.168.1.108]

PLAY RECAP ***********************************************************************************************************************************************************************************
192.168.1.105              : ok=4    changed=3    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0
192.168.1.106              : ok=4    changed=3    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0
192.168.1.107              : ok=4    changed=3    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0
192.168.1.108              : ok=4    changed=2    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0

Troubleshooting ansible commands and results

You can use the -v parameter to any ansible command to get more information on what is happening and why.
You can see below that the firewall-cmd command was only run on the Redhat/Almalinux machine, as in the yaml file there is a condition - when: ansible_distribution == "AlmaLinux".

$ ansible-playbook test.yml --ask-become-pass -v
Using /home/george/ansible/ansible.cfg as config file
BECOME password:

PLAY [all] ***********************************************************************************************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************************************************************************************
ok: [192.168.1.106]
ok: [192.168.1.105]
ok: [192.168.1.107]
ok: [192.168.1.108]

TASK [Add firewall rule to open port 80 on host - Redhat] ************************************************************************************************************************************
skipping: [192.168.1.105] => {"changed": false, "false_condition": "(ansible_facts['distribution'] == \"AlmaLinux\")", "skip_reason": "Conditional result was False"}
skipping: [192.168.1.106] => {"changed": false, "false_condition": "(ansible_facts['distribution'] == \"AlmaLinux\")", "skip_reason": "Conditional result was False"}
skipping: [192.168.1.107] => {"changed": false, "false_condition": "(ansible_facts['distribution'] == \"AlmaLinux\")", "skip_reason": "Conditional result was False"}
changed: [192.168.1.108] => {"changed": true, "cmd": ["firewall-cmd", "--add-port=80/tcp"], "delta": "0:00:00.217253", "end": "2025-02-10 19:16:02.997019", "msg": "", "rc": 0, "start": "2025-02-10 19:16:02.779766", "stderr": "", "stderr_lines": [], "stdout": "success", "stdout_lines": ["success"]}

PLAY RECAP ***********************************************************************************************************************************************************************************
192.168.1.105              : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
192.168.1.106              : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
192.168.1.107              : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
192.168.1.108              : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The corresponding playbook:

---

- hosts: all
  become: true
  tasks:
    - name: Add firewall rule to open port 80 on host - Redhat
      ansible.builtin.command: firewall-cmd --add-port=80/tcp
      when: (ansible_facts['distribution'] == "AlmaLinux")

Consolidating the playbook

We can add the update_cache statement as part of the packages install section and we can add packages in a list below the name: attribute:

---

- hosts: all
  become: true
  tasks:

    - name: Install apache packages - Debian
      apt:
        name:
        - apache2
        - libapache2-mod-php
        state: latest
        update_cache: yes
      when: ansible_distribution == "Debian"
# This is for AlmaLinux, a Redhat compatible linux
    - name: Install apache packages - Redhat
      dnf:
        name:
        - httpd
        - php
        state: latest
        update_cache: yes
      when: ansible_distribution == "AlmaLinux"
    - name: Add firewall rule to open port 80 on host - Redhat
      ansible.builtin.command: firewall-cmd --add-port=80/tcp
      when: (ansible_facts['distribution'] == "AlmaLinux")

Using groups and tags

We can group the machines as per below, for example [web_servers]:

[web_servers]
192.168.1.105
192.168.1.108
[file_servers]
192.168.1.106
[database_servers]
192.168.1.107

Below is a section that installs apache web server to only the web_servers hosts:

- hosts: web_servers
  become: true
  tasks:
    - name: Install apache packages - Debian
      apt:
        name:
        - apache2
        - libapache2-mod-php
        state: latest
      when: ansible_distribution == "Debian"

We can also use the pre_tasks section at the beginning to the playbook to update the cache and applications before installing any package:

- hosts: all
  become: true
  pre_tasks:
    - name: Update cache and applications - Debian
      apt:
        upgrade: dist
        update_cache: yes
      when: ansible_distribution == "Debian

We can also use tags to better organise the playbook. The sections with tags: always are always run, even when specifying --tags.

- hosts: all
  become: true
  pre_tasks:
    - name: Update cache and applications - Debian
      tags: always
      apt:
        upgrade: dist
        update_cache: yes
      when: ansible_distribution == "Debian"

- hosts: web_servers
  become: true
  tasks:
    - name: Install apache packages - Debian
      tags: apache,debian
      apt:
        name:
        - apache2
        - libapache2-mod-php
        state: latest
      when: ansible_distribution == "Debian"

For example, we can run the playbook only on servers tagged apache. Note that the inventory file has only two:

[web_servers]
192.168.1.105
192.168.1.108

We can see that, besides the pre-tasks with tags: always, only the sections with the two web servers having the tag apache ran:

$ ansible-playbook --tags apache install_apache-7.yml --ask-become-pass
BECOME password:

PLAY [all] ***********************************************************************************

TASK [Gathering Facts] ***********************************************************************
ok: [192.168.1.106]
ok: [192.168.1.105]
ok: [192.168.1.107]
ok: [192.168.1.108]

TASK [Update cache and applications - Debian] ************************************************
skipping: [192.168.1.108]
ok: [192.168.1.105]
ok: [192.168.1.106]
ok: [192.168.1.107]

TASK [Update cache and applications - Redhat] ************************************************
skipping: [192.168.1.105]
skipping: [192.168.1.106]
skipping: [192.168.1.107]
ok: [192.168.1.108]

PLAY [web_servers] ***************************************************************************

TASK [Gathering Facts] ***********************************************************************
ok: [192.168.1.105]
ok: [192.168.1.108]

TASK [Install apache packages - Debian] ******************************************************
skipping: [192.168.1.108]
ok: [192.168.1.105]

TASK [Install apache packages - Redhat] ******************************************************
skipping: [192.168.1.105]
ok: [192.168.1.108]

Activating and enabling systemd services

Wehn the web server is installed on Redhat, the corresponding httpd service is not enabled and started. To do that, we can add a section in the playbook:

$ systemctl status httpd.service
○ httpd.service - The Apache HTTP Server
     Loaded: loaded (/usr/lib/systemd/system/httpd.service; disabled; preset: disabled)
    Drop-In: /usr/lib/systemd/system/httpd.service.d
             └─php-fpm.conf
     Active: inactive (dead)

Let’s add this section to the playbook, it enables and starts the web server on Redhat:

    - name: Enable apache systemd service - Redhat
      tags: apache,systemd,redhat
      service:
        name: httpd
        state: started
        enabled: yes
      when: ansible_distribution == "AlmaLinux"

We can now see that the httpd service is enabled and started:

$ systemctl status httpd.service
● httpd.service - The Apache HTTP Server
     Loaded: loaded (/usr/lib/systemd/system/httpd.service; enabled; preset: disabled)
    Drop-In: /usr/lib/systemd/system/httpd.service.d
             └─php-fpm.conf
     Active: active (running) since Mon 2025-02-10 21:08:09 GMT; 6s ago
       Docs: man:httpd.service(8)
   Main PID: 26279 (httpd)
     Status: "Started, listening on: port 80"