Structuring Complex Ansible Playbooks on RHEL

AnsibleBeginner
Practice Now

Introduction

In this lab, you will learn essential techniques for structuring complex Ansible playbooks on RHEL to create more manageable, scalable, and reusable automation. You will progress from fundamental concepts to advanced organizational strategies, focusing on how to precisely control which hosts your automation runs on and how to break down large playbooks into logical, modular components.

You will begin by mastering host selection, using basic group names, wildcards, exclusions, and logical operators to target specific nodes within your inventory. Next, you will explore modularization by refactoring tasks into separate files using include_tasks and import_tasks. Finally, you will learn to compose a complete, multi-playbook workflow with import_playbook, culminating in the execution and verification of your fully structured and modularized Ansible project.

Selecting Hosts with Basic and Wildcard Patterns

In this step, you will learn the fundamentals of targeting specific hosts in your Ansible automation. The core of this is the Ansible inventory file, which lists the servers you manage, and the hosts directive within a playbook, which specifies which of those servers a set of tasks should run against. We will start by installing Ansible, creating a basic inventory and playbook, and then exploring how to select hosts using group names and wildcard patterns.

First, let's ensure Ansible is installed in your environment. Ansible is not installed by default, so you need to install it using the DNF package manager. The ansible-core package provides the essential Ansible command-line tools, including ansible-playbook. Run the following command:

sudo dnf install -y ansible-core

You should see output similar to this:

...
Installed:
  ansible-core-2.16.x-x.el9.x86_64
  ...
Complete!

Next, let's create a dedicated directory for this exercise to keep our files organized. All subsequent actions in this step will take place within this new directory:

mkdir -p ~/project/ansible_patterns
cd ~/project/ansible_patterns

Now, create an inventory file. An inventory is a text file that defines the hosts and groups of hosts upon which Ansible commands, modules, and tasks operate. We will use the INI format for its simplicity.

Use the nano editor to create a file named inventory:

nano inventory

Add the following content to the inventory file. This defines two groups, webservers and dbservers, each containing two hosts:

[webservers]
web1.example.com
web2.example.com

[dbservers]
db1.lab.net
db2.lab.net

Save the file and exit nano by pressing Ctrl+X, then Y, and Enter.

With our inventory ready, let's create a simple playbook. This playbook will use the ansible.builtin.debug module to print a message, confirming which host the task is running on. This is a great way to test host patterns without making any actual changes to the system.

Create a new file named playbook.yml:

nano playbook.yml

Add the following YAML content. Initially, it targets all hosts in the webservers group:

---
- name: Test Host Patterns
  hosts: webservers
  gather_facts: false
  tasks:
    - name: Display the inventory hostname
      ansible.builtin.debug:
        msg: "This task is running on {{ inventory_hostname }}"

Save and exit the editor. Now, run the playbook using ansible-playbook. The -i flag specifies our custom inventory file:

ansible-playbook playbook.yml -i inventory

The output should look like this:

PLAY [Test Host Patterns] ******************************************************

TASK [Display the inventory hostname] ******************************************
ok: [web1.example.com] => {
    "msg": "This task is running on web1.example.com"
}
ok: [web2.example.com] => {
    "msg": "This task is running on web2.example.com"
}

PLAY RECAP *********************************************************************
web1.example.com           : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web2.example.com           : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

As you can see, only the hosts from the webservers group were targeted. Now, let's modify the playbook to use a wildcard (*). Wildcards allow for more flexible pattern matching.

Edit playbook.yml and change the hosts line to hosts: "*.lab.net". Remember to enclose patterns containing wildcards in quotes:

---
- name: Test Host Patterns
  hosts: "*.lab.net"
  gather_facts: false
  tasks:
    - name: Display the inventory hostname
      ansible.builtin.debug:
        msg: "This task is running on {{ inventory_hostname }}"

Run the playbook again:

ansible-playbook playbook.yml -i inventory

You should see output similar to this:

PLAY [Test Host Patterns] ******************************************************

TASK [Display the inventory hostname] ******************************************
ok: [db1.lab.net] => {
    "msg": "This task is running on db1.lab.net"
}
ok: [db2.lab.net] => {
    "msg": "This task is running on db2.lab.net"
}

PLAY RECAP *********************************************************************
db1.lab.net                : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
db2.lab.net                : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

This time, the play ran only on hosts whose names end with .lab.net. Finally, let's use the special keyword all to target every host defined in the inventory.

Edit playbook.yml one last time and change the hosts line to hosts: all:

---
- name: Test Host Patterns
  hosts: all
  gather_facts: false
  tasks:
    - name: Display the inventory hostname
      ansible.builtin.debug:
        msg: "This task is running on {{ inventory_hostname }}"

Run the playbook to see the result:

ansible-playbook playbook.yml -i inventory

The output will show all hosts being targeted:

PLAY [Test Host Patterns] ******************************************************

TASK [Display the inventory hostname] ******************************************
ok: [web1.example.com] => {
    "msg": "This task is running on web1.example.com"
}
ok: [web2.example.com] => {
    "msg": "This task is running on web2.example.com"
}
ok: [db1.lab.net] => {
    "msg": "This task is running on db1.lab.net"
}
ok: [db2.lab.net] => {
    "msg": "This task is running on db2.lab.net"
}

PLAY RECAP *********************************************************************
db1.lab.net                : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
db2.lab.net                : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web1.example.com           : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web2.example.com           : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The playbook now runs on all four hosts from your inventory, demonstrating the power of the all pattern.

Refining Host Selection with Exclusions and Logical Operators

In this step, you will advance your host selection skills by learning how to use exclusions and logical operators. These features allow for highly specific targeting, which is essential when managing complex environments. You will learn to exclude hosts using the ! (NOT) operator and combine groups using the & (AND) operator. We will continue working with the inventory and playbook.yml files from the previous step.

First, ensure you are in the correct working directory:

cd ~/project/ansible_patterns

To effectively demonstrate logical operators, we need to create some overlap in our host groups. Let's edit the inventory file to add a new group called production that contains one web server and one database server:

nano inventory

Add the [production] group and its members to the end of the file:

[webservers]
web1.example.com
web2.example.com

[dbservers]
db1.lab.net
db2.lab.net

[production]
web1.example.com
db1.lab.net

Save the file and exit nano by pressing Ctrl+X, then Y, and Enter.

Now, let's practice exclusion. The ! operator, which means NOT, allows you to exclude a host or group from a selection. Modify your playbook.yml to target all hosts except those in the dbservers group:

nano playbook.yml

Update the hosts line as shown below. The pattern all,!dbservers selects every host and then removes any that are in the dbservers group:

---
- name: Test Host Patterns
  hosts: all,!dbservers
  gather_facts: false
  tasks:
    - name: Display the inventory hostname
      ansible.builtin.debug:
        msg: "This task is running on {{ inventory_hostname }}"

Save and exit the editor, then run the playbook:

ansible-playbook playbook.yml -i inventory

You should see only the web servers being targeted:

PLAY [Test Host Patterns] ******************************************************

TASK [Display the inventory hostname] ******************************************
ok: [web1.example.com] => {
    "msg": "This task is running on web1.example.com"
}
ok: [web2.example.com] => {
    "msg": "This task is running on web2.example.com"
}

PLAY RECAP *********************************************************************
web1.example.com           : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web2.example.com           : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

As expected, only the hosts from the webservers group were targeted.

Next, let's explore the logical AND operator. The & operator selects only the hosts that exist in both specified groups (an intersection). Let's modify the playbook to target hosts that are in the webservers group AND also in the production group:

nano playbook.yml

Change the hosts line to webservers,&production:

---
- name: Test Host Patterns
  hosts: webservers,&production
  gather_facts: false
  tasks:
    - name: Display the inventory hostname
      ansible.builtin.debug:
        msg: "This task is running on {{ inventory_hostname }}"

Save and run the playbook:

ansible-playbook playbook.yml -i inventory

This time, only the intersection of both groups will be targeted:

PLAY [Test Host Patterns] ******************************************************

TASK [Display the inventory hostname] ******************************************
ok: [web1.example.com] => {
    "msg": "This task is running on web1.example.com"
}

PLAY RECAP *********************************************************************
web1.example.com           : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The output correctly shows that only web1.example.com was targeted, as it is the only host that is a member of both the webservers and production groups. These operators give you precise control over which hosts your automation affects.

Modularizing a Play with include_tasks and import_tasks

In this step, you will learn how to structure larger Ansible projects by breaking them down into smaller, reusable files. As playbooks grow, keeping all tasks in a single file becomes difficult to manage. Ansible provides two main directives for this: import_tasks and include_tasks. Both allow you to pull in tasks from another file.

  • import_tasks is static. It is processed when the playbook is first parsed by Ansible. This is best for unconditional, structural parts of your play.
  • include_tasks is dynamic. It is processed during the execution of the play. This makes it suitable for use with loops and conditionals.

We will now refactor our playbook to use both. First, ensure you are in the project directory:

cd ~/project/ansible_patterns

Before we proceed, let's update the inventory file to make the hosts point to localhost for this lab environment. This will allow the playbook to run successfully:

nano inventory

Replace the content with the following configuration that maps the example hosts to localhost:

[webservers]
web1.example.com ansible_host=localhost ansible_connection=local
web2.example.com ansible_host=localhost ansible_connection=local

[dbservers]
db1.lab.net ansible_host=localhost ansible_connection=local
db2.lab.net ansible_host=localhost ansible_connection=local

Save and exit the editor. This configuration uses ansible_host=localhost to redirect connections to the local machine and ansible_connection=local to avoid SSH connection attempts.

A common practice is to store reusable task files in a dedicated subdirectory. Let's create one named tasks:

mkdir tasks

Now, let's create a file for common setup tasks that might apply to many servers. We'll place a task to install the httpd web server package here:

nano tasks/web_setup.yml

Add the following content. Note that this file is just a list of tasks; it does not contain a full play structure (like hosts: or name:):

- name: Install the httpd package
  ansible.builtin.dnf:
    name: httpd
    state: present
  become: true

Save and exit nano. Next, create a second task file for a simple verification step:

nano tasks/verify_config.yml

Add the following debug task to this file:

- name: Display a verification message
  ansible.builtin.debug:
    msg: "Configuration tasks applied to {{ inventory_hostname }}"

Save and exit the editor. Now, let's modify the main playbook.yml to use these new task files. We will use import_tasks for the static setup and include_tasks for the dynamic verification message:

nano playbook.yml

Replace the entire content of playbook.yml with the following. This playbook now targets the webservers group and uses the modular task files:

---
- name: Configure Web Servers
  hosts: webservers
  gather_facts: false
  tasks:
    - name: Import web server setup tasks
      import_tasks: tasks/web_setup.yml

    - name: Include verification tasks
      include_tasks: tasks/verify_config.yml

Save the file and run the playbook:

ansible-playbook playbook.yml -i inventory

You should see the modular tasks being executed:

PLAY [Configure Web Servers] ***************************************************

TASK [Import web server setup tasks] *******************************************
imported: /home/labex/project/ansible_patterns/tasks/web_setup.yml

TASK [Install the httpd package] ***********************************************
changed: [web1.example.com]
changed: [web2.example.com]

TASK [Include verification tasks] **********************************************
included: /home/labex/project/ansible_patterns/tasks/verify_config.yml for web1.example.com, web2.example.com

TASK [Display a verification message] ******************************************
ok: [web1.example.com] => {
    "msg": "Configuration tasks applied to web1.example.com"
}
ok: [web2.example.com] => {
    "msg": "Configuration tasks applied to web2.example.com"
}

PLAY RECAP *********************************************************************
web1.example.com           : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web2.example.com           : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Observe how the output clearly indicates when tasks are being imported and included from their respective files. This modular approach makes your automation cleaner and easier to maintain.

Composing a Workflow with import_playbook

In this step, you will learn to orchestrate entire playbooks to form a complex workflow using import_playbook. While import_tasks and include_tasks are for reusing lists of tasks within a single play, import_playbook operates at a higher level. It allows you to create a master playbook that executes other, self-contained playbooks in a specific order. This is the standard way to manage large-scale automation, such as provisioning an entire application stack.

First, let's ensure we are in the correct directory and organize our project for this new structure:

cd ~/project/ansible_patterns

It is a best practice to store individual, component playbooks in a dedicated subdirectory. Let's create a directory named playbooks:

mkdir playbooks

Now, move the playbook we created in the last step, which configures web servers, into this new directory. Renaming it to be more descriptive is also a good idea:

mv playbook.yml playbooks/web_configure.yml

However, since we moved the playbook to a subdirectory, we need to update the relative paths to the task files. The task files are still in the tasks/ directory relative to the main project directory, so we need to adjust the paths:

nano playbooks/web_configure.yml

Update the paths in the playbook to use ../tasks/ instead of tasks/:

---
- name: Configure Web Servers
  hosts: webservers
  gather_facts: false
  tasks:
    - name: Import web server setup tasks
      import_tasks: ../tasks/web_setup.yml

    - name: Include verification tasks
      include_tasks: ../tasks/verify_config.yml

Save and exit the editor.

Let's test the corrected playbook to ensure the paths are working correctly:

ansible-playbook playbooks/web_configure.yml -i inventory

You should see the playbook execute successfully with the corrected paths.

Next, create a new, separate playbook for configuring your database servers. This playbook will target the dbservers group and install the mariadb package:

nano playbooks/db_setup.yml

Add the following content to the file. This is a complete, standalone play:

---
- name: Configure Database Servers
  hosts: dbservers
  gather_facts: false
  tasks:
    - name: Install mariadb package
      ansible.builtin.dnf:
        name: mariadb
        state: present
      become: true

    - name: Display a confirmation message
      ansible.builtin.debug:
        msg: "Database server {{ inventory_hostname }} configured."

Save and exit the editor. Now you have two component playbooks: one for web servers and one for database servers.

Finally, create a top-level "main" playbook. This file will not contain any hosts or tasks itself. Its only job is to import the other playbooks in the correct order to define the overall workflow:

nano main.yml

Add the following content. This creates a workflow that first configures the web servers and then configures the database servers:

---
- name: Import the web server configuration play
  import_playbook: playbooks/web_configure.yml

- name: Import the database server configuration play
  import_playbook: playbooks/db_setup.yml

Save and exit nano. You are now ready to execute your entire workflow by running the main.yml playbook:

ansible-playbook main.yml -i inventory

The output will show both playbooks being executed in sequence:

PLAY [Configure Web Servers] ***************************************************

TASK [Import web server setup tasks] *******************************************
imported: /home/labex/project/ansible_patterns/playbooks/../tasks/web_setup.yml

TASK [Install the httpd package] ***********************************************
ok: [web1.example.com]
ok: [web2.example.com]

TASK [Include verification tasks] **********************************************
included: /home/labex/project/ansible_patterns/playbooks/../tasks/verify_config.yml for web1.example.com, web2.example.com

TASK [Display a verification message] ******************************************
ok: [web1.example.com] => {
    "msg": "Configuration tasks applied to web1.example.com"
}
ok: [web2.example.com] => {
    "msg": "Configuration tasks applied to web2.example.com"
}

PLAY [Configure Database Servers] **********************************************

TASK [Install mariadb package] *************************************************
changed: [db1.lab.net]
changed: [db2.lab.net]

TASK [Display a confirmation message] ******************************************
ok: [db1.lab.net] => {
    "msg": "Database server db1.lab.net configured."
}
ok: [db2.lab.net] => {
    "msg": "Database server db2.lab.net configured."
}

PLAY RECAP *********************************************************************
db1.lab.net                : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
db2.lab.net                : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web1.example.com           : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web2.example.com           : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The output clearly shows two separate plays being executed in sequence, demonstrating how import_playbook effectively composes a larger workflow from smaller, manageable parts.

Executing and Verifying the Complete Modular Playbook

In this final step, you will execute the complete, modular workflow you have built and, more importantly, learn how to verify that the automation has achieved the desired state on the target systems. A successful playbook run is good, but confirming the outcome is essential for reliable automation.

First, ensure you are in the main project directory:

cd ~/project/ansible_patterns

Before running the final playbook, let's visualize the complete project structure you've created. The tree command is excellent for this. If it's not installed, you can add it with dnf:

sudo dnf install -y tree
tree .

You should see a structure like this:

.
├── inventory
├── main.yml
├── playbooks
│   ├── db_setup.yml
│   └── web_configure.yml
└── tasks
    ├── verify_config.yml
    └── web_setup.yml

2 directories, 6 files

This structure, with a main entry point (main.yml), separate playbook files, and reusable task files, is a scalable and maintainable way to manage Ansible projects.

Now, execute the entire workflow by running your top-level main.yml playbook:

ansible-playbook main.yml -i inventory

After the playbook completes successfully, the next crucial step is verification. You need to confirm that the system is in the state you intended. Our playbook was designed to install the httpd package on web servers and the mariadb package on database servers. Since all tasks in this lab run on your local machine, we can verify their installation directly using the rpm command.

First, check if the httpd package was installed as part of the web server configuration:

rpm -q httpd

You should see output confirming the package is installed:

httpd-2.4.xx-x.el9.x86_64

Next, verify the mariadb package installation from the database server configuration:

rpm -q mariadb

Similarly, you should see confirmation that mariadb is installed:

mariadb-10.5.xx-x.el9.x86_64

Seeing the package names in the output confirms that your Ansible playbook successfully configured the system as intended. You have now successfully built, executed, and verified a modular Ansible project from start to finish.

Summary

In this lab, you learned essential techniques for structuring complex Ansible playbooks on RHEL. You began with the fundamentals of host selection, using basic group names, wildcards, exclusions, and logical operators to precisely target nodes defined in an inventory file. The focus then shifted to modularization, where you practiced breaking down large plays into more manageable and reusable components using both include_tasks for dynamic inclusion and import_tasks for static inclusion.

Building on these skills, you learned to compose a complete, multi-stage workflow by linking individual playbooks together with import_playbook. The hands-on process involved installing Ansible, creating a project structure, and progressively refactoring a simple playbook into a sophisticated, multi-file structure. The lab culminated in executing the final composite playbook and verifying that the entire automated workflow ran successfully against the correctly targeted hosts, demonstrating an organized and scalable approach to automation.