Reusability #
Automating infrastructure with Ansible isn’t just about writing a set of tasks that successfully run once on a single target server. At industrial scale and in enterprise production environments, automation efficiency is largely determined by how well you design components to be reusable across various projects, environments (development, staging, production), and even different operating systems. This ability to reuse automation code is known as the reusability principle. Without careful design planning, you often fall into the trap of rewriting the same code for new projects, leading to wasted time, high potential for human error, and difficulty standardizing infrastructure.
The Abstraction Philosophy in Role Design #
Designing reusable roles demands that you think declaratively and strictly separate execution logic (how something is installed and configured) from configuration data (what specific configuration values apply to a particular server). You should treat Ansible roles like functions or libraries in modular programming. A good function accepts input parameters, processes them with encapsulated internal logic, and produces output without hard dependencies on irrelevant external state.
In the Ansible context, this encapsulation is realized by turning every dynamic value that varies between projects into an Ansible variable. By abstracting aspects like package versions, file paths, network ports, usernames, and service configuration options, you can ensure that the only thing role users need to adjust is the variable file, while all task files inside the role stay clean and untouched. This minimizes the risk of syntax errors from directly modifying main task files.
Rigid vs Reusable Role Comparison #
To understand why designing reusable roles is so crucial, let’s compare two extreme approaches to implementing a role that configures the Apache web server service. We’ll see how a rigid design drastically limits its usefulness, and how a flexible (reusable) design solves that problem.
Anti-Pattern Code: Rigid Role #
In the example below, we see a very rigid Apache configuration task. All parameters are hardcoded inside the task file and configuration template.
# ANTI-PATTERN: Locking configuration values hard (hardcoded)
# File: roles/rigid_apache/tasks/main.yml
---
- name: Install Apache Web Server
apt:
name: apache2
state: present
update_cache: true
- name: Copy the main Apache configuration file
copy:
src: httpd.conf
dest: /etc/apache2/apache2.conf
owner: www-data
group: www-data
mode: '0644'
- name: Ensure Apache is running and enabled at boot
service:
name: apache2
state: started
enabled: true
The main problem with the code above is its absolute dependence on the Debian OS family (using the apt module and the apache2 package name), the specific configuration directory path (/etc/apache2/apache2.conf), and the hardcoded system user/group names (www-data). If you want to use this role on RedHat Enterprise Linux (RHEL) servers, you’re forced to rewrite the entire role or create a duplicate with a new name.
Solution Code: Reusable Role #
Now, let’s transform that design into a reusable component by leveraging default variables and dynamic writing patterns.
# CORRECT: Using variables to separate logic from configuration data
# File: roles/reusable_apache/defaults/main.yml
---
apache_package_name: apache2
apache_service_name: apache2
apache_config_path: /etc/apache2/apache2.conf
apache_owner: www-data
apache_group: www-data
apache_config_template: httpd.conf.j2
# File: roles/reusable_apache/tasks/main.yml
---
- name: Install the Apache package
package:
name: "{{ apache_package_name }}"
state: present
- name: Copy the main Apache configuration file from the Jinja2 template
template:
src: "{{ apache_config_template }}"
dest: "{{ apache_config_path }}"
owner: "{{ apache_owner }}"
group: "{{ apache_group }}"
mode: '0644'
notify: Restart Apache Service
- name: Ensure the Apache service runs according to configuration
service:
name: "{{ apache_service_name }}"
state: started
enabled: true
By switching to the generic package module instead of apt, and abstracting all package names, file paths, and file owners into default variables, you’ve drastically increased this role’s flexibility. Role users now only need to redefine those variables at the playbook level without modifying a single line of code inside the tasks/ directory.
Role Characteristics Comparison Table #
Here’s a summary of the characteristic comparison between Rigid Roles and Reusable Roles to help you identify the quality of the automation code you write:
| Evaluation Dimension | Rigid Role (Anti-Pattern) | Reusable Role (Recommended Solution) |
|---|---|---|
| Ease of Maintenance | Very low; every infrastructure detail change forces editing the main task files. | Very high; configuration changes are done through variable values. |
| Operating System Support | Locked to one specific OS or distribution (e.g. Ubuntu). | Multi-platform (OS-agnostic); dynamically adapts to the target distribution. |
| Configuration Method | Parameter values statically encoded in task files or raw config files. | Uses fully parameterized dynamic Jinja2 templates. |
| Code Coupling | Very tight with specific environments (highly coupled); hard to move to other projects. | Very loose (loosely coupled); ready to integrate as an external library. |
| Testing Ease | Hard to test in isolation because it needs servers with specific configuration. | Easy to test with mock parameters or in automated CI/CD environments. |
| Code Duplication Potential | High; encourages copy-paste habits of role folders for new project scenarios. | Low; a single role is consistently referenced by many different playbooks. |
| Scalability Support | Unable to handle configuration replication with slightly varied options. | Very good; can be called repeatedly with unique instantiation parameters. |
| Credential Security | Vulnerable; secrets are often written directly in task files committed to Git. | Safe; relies on Ansible Vault integration or lookup plugins for sensitive data. |
OS-Agnostic Modularity Strategy #
The biggest challenge in designing reusable roles is operating system diversity in data centers. An organization might use RedHat Enterprise Linux for backend databases for stability and commercial support reasons, but use Ubuntu Server for frontends and microservices to leverage a faster-updating package ecosystem. If you write one role specifically for Ubuntu and one specifically for RHEL to configure the same service (for example, a monitoring agent), you’re consciously doubling your code maintenance burden.
The key to OS-agnostic modularity is hiding OS-level implementation differences behind abstraction variables. You shouldn’t scatter when: ansible_os_family == 'Debian' conditions across every task line. Excessive when-based approaches on individual tasks make task files very long, hard to read, and inefficient because Ansible still evaluates those tasks even though they end up skipped.
Instead, you should leverage the fact collection system (Ansible Facts) intelligently. Ansible automatically detects target system information at the start of execution. The two facts most often used for OS modularity are:
ansible_os_family: Groups Linux distributions into large families likeDebian(including Ubuntu, Mint),RedHat(including RHEL, Rocky Linux, AlmaLinux, CentOS), orAlpine.ansible_distribution: Precisely refers to the specific distribution name likeUbuntu,Debian,CentOS, orRocky.
By understanding this difference, you can design a dynamic variable loading structure that automatically picks the right parameters based on the target OS Ansible is facing.
OS Family-Based Dynamic Task Loading Technique #
To implement multi-OS modularity cleanly, you use a combination of include_vars and include_tasks techniques. This pattern lets you separate different installation tasks into separate files, then load them dynamically when the playbook runs.
Recommended Role Directory Structure #
The directory structure below shows a tidy OS-based task and variable separation:
roles/reusable_webserver/
├── defaults/
│ └── main.yml # Global default variables
├── vars/
│ ├── Debian.yml # Debian/Ubuntu family-specific variables
│ ├── RedHat.yml # RHEL/CentOS/Rocky family-specific variables
│ └── default.yml # Fallback variables if the OS is unrecognized
├── tasks/
│ ├── main.yml # Main task flow
│ ├── install-Debian.yml # Debian/Ubuntu installation procedure
│ ├── install-RedHat.yml # RHEL/CentOS installation procedure
│ └── configure.yml # Application configuration (OS-agnostic)
└── templates/
└── webserver.conf.j2 # Main configuration template
Main Logic File Implementation (tasks/main.yml)
#
This file acts as the main conductor tasked with detecting the target OS, loading the right variables, calling the right installation tasks, then continuing to the common configuration.
# File: roles/reusable_webserver/tasks/main.yml
---
# ✓ DYNAMIC VARIABLE LOADING
- name: Load operating system family-specific variables
include_vars: "{{ item }}"
with_first_found:
- files:
- "{{ ansible_os_family }}.yml"
- "default.yml"
paths:
- "../vars"
tags: [always]
# ✓ DYNAMIC INSTALLATION TASK CALLING
- name: Run the package installation procedure according to the OS family
include_tasks: "install-{{ ansible_os_family }}.yml"
tags: [install]
# ✓ RUNNING COMMON CONFIGURATION TASKS (OS-AGNOSTIC)
- name: Apply the web server service configuration
include_tasks: configure.yml
tags: [configure]
OS-Specific Variable Implementation (vars/Debian.yml and vars/RedHat.yml)
#
Here we define concrete values for parameters that differ between the two operating system families.
# File: roles/reusable_webserver/vars/Debian.yml
---
webserver_package: apache2
webserver_service: apache2
webserver_config_dir: /etc/apache2
webserver_config_file: "{{ webserver_config_dir }}/sites-available/000-default.conf"
webserver_user: www-data
webserver_group: www-data
# File: roles/reusable_webserver/vars/RedHat.yml
---
webserver_package: httpd
webserver_service: httpd
webserver_config_dir: /etc/httpd
webserver_config_file: "{{ webserver_config_dir }}/conf.d/welcome.conf"
webserver_user: apache
webserver_group: apache
OS-Specific Installation Task Implementation (tasks/install-Debian.yml and tasks/install-RedHat.yml)
#
These tasks focus exclusively on how packages are installed on each OS, using native package manager modules for best performance and reliability.
# File: roles/reusable_webserver/tasks/install-Debian.yml
---
- name: Update the apt cache and install the Apache2 package (Debian/Ubuntu)
apt:
name: "{{ webserver_package }}"
state: present
update_cache: true
register: apt_install_result
until: apt_install_result is success
retries: 3
delay: 5
# File: roles/reusable_webserver/tasks/install-RedHat.yml
---
- name: Install the httpd package using DNF (RHEL/CentOS/Rocky)
dnf:
name: "{{ webserver_package }}"
state: present
register: dnf_install_result
until: dnf_install_result is success
retries: 3
delay: 5
OS-Agnostic Configuration Task Implementation (tasks/configure.yml)
#
After the package is successfully installed using the OS-specific method, the actual application configuration is usually the same across all platforms. You reference the variables loaded dynamically earlier.
# File: roles/reusable_webserver/tasks/configure.yml
---
- name: Create the configuration directory if not yet available
file:
path: "{{ webserver_config_dir }}"
state: directory
owner: "{{ webserver_user }}"
group: "{{ webserver_group }}"
mode: '0755'
- name: Copy the configuration file from the Jinja2 template
template:
src: webserver.conf.j2
dest: "{{ webserver_config_file }}"
owner: "{{ webserver_user }}"
group: "{{ webserver_group }}"
mode: '0644'
notify: Trigger Web Server Restart
Dynamic Configuration Abstraction Using Jinja2 #
Using Jinja2 templates (.j2) is a vital foundation for making reusable configuration files. Often, you’re tempted to copy a complete static configuration file from a reference server into the files/ folder inside the role. This is a bad practice because if one port or one domain name needs changing later, you’re forced to edit that file and limit the role’s flexibility for other servers.
The correct approach is writing configuration in Jinja2 template format and exposing important configuration options as Ansible variables. Here’s an example of creating a very flexible Apache web server configuration template:
{# File: roles/reusable_webserver/templates/webserver.conf.j2 #}
# This file is automatically managed by Ansible.
# Manual changes will be overwritten when the playbook is re-run.
VirtualHost *:{{ webserver_listen_port | default(80) }}>
ServerAdmin {{ webserver_admin_email | default('webmaster@localhost') }}
DocumentRoot {{ webserver_document_root | default('/var/www/html') }}
ServerName {{ webserver_domain_name | mandatory }}
<Directory {{ webserver_document_root | default('/var/www/html') }}>
Options {{ webserver_directory_options | default('Indexes FollowSymLinks') }}
AllowOverride {{ webserver_directory_allow_override | default('None') }}
Require all granted
</Directory>
LogLevel {{ webserver_log_level | default('warn') }}
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
In the template above, note the use of Jinja2 filters:
| default(value)ensures that if the role user doesn’t define that variable in their playbook, Ansible uses the safe default value you specified. This keeps the role easy to use directly without much configuration.| mandatoryis how you force users to define certain critical variables (in this casewebserver_domain_name). If this variable is empty when the playbook runs, Ansible immediately stops execution with a clear error message, instead of deploying a broken configuration.
Integrating Handlers Reusably #
Handlers are used in Ansible to react to task status changes, like restarting a service after its configuration file is updated. However, writing handlers for a reusable role requires extra care to be compatible with various service names on different operating systems.
Apply the handler naming principle using dynamic variables so you don’t need to duplicate handlers for every operating system.
# File: roles/reusable_webserver/handlers/main.yml
---
- name: Trigger Web Server Restart
service:
name: "{{ webserver_service }}"
state: restarted
when:
- webserver_service is defined
- not ansible_check_mode
In the handler above, we added the when: not ansible_check_mode condition. This condition is very important to make sure that if you run Ansible in simulation mode (--check), the handler won’t try to do a real service restart on target servers, which could cause false errors.
Additionally, if you have important tasks depending on the just-restarted service (for example, doing an HTTP connection test to the newly configured Apache server), you must trigger handler execution instantly mid-flow using the meta: flush_handlers module:
# Example of triggering handlers instantly inside tasks
- name: Force run all pending handlers right now
meta: flush_handlers
- name: Perform the HTTP service health verification
uri:
url: "http://localhost:{{ webserver_listen_port | default(80) }}"
status_code: 200
register: health_check_result
Reusable Role Design Decision Flowchart #
To make the decision process easier when designing reusable Ansible roles, you can use the following decision flowchart as a structured guide:
flowchart TD
A["Start Role Design"] --> B{"Does it run on multi-OS?"}
B -- "Yes" --> C["Split variables into vars/OS.yml"]
C --> D["Split installation into tasks/install-OS.yml"]
D --> E["Use include_vars & include_tasks in main.yml"]
B -- "No" --> F["Use a single task in tasks/main.yml"]
F --> G["Define variables in defaults/main.yml"]
E --> G
G --> H{"Is the configuration dynamic?"}
H -- "Yes" --> I["Abstract the file into templates/config.conf.j2"]
I --> J["Use the default and mandatory filters"]
H -- "No" --> K["Use the copy module with a dest variable"]
K --> L["Use dynamic variable-based handlers"]
J --> L
L --> M["Do parameter validation in pre_tasks"]
M --> N["Reusable Role Finished"]Reusability Checklist for Ansible Developers #
Before publishing or distributing the Ansible role you created to a shared repository, make sure to evaluate your code against the quality checklist below:
VARIABLES & DEFAULT VALUES:
□ All concrete values (IPs, ports, paths, users) are turned into variables.
□ Sensitive variables (passwords, API keys) aren't written directly (abstracted).
□ Using defaults/main.yml for all optional parameters.
□ Using the 'mandatory' filter for all required parameters.
□ Variable names use the role name prefix (e.g.: 'myrole_port').
TASK & MODULARITY DESIGN:
□ Main tasks (tasks/main.yml) are clean of wordy logic.
□ Installation logic is separated from configuration and administration logic.
□ Using built-in modules (apt, yum, file) instead of 'command'/'shell' modules.
□ Every task is idempotent (safe to run repeatedly).
□ Using tactical tags (install, configure, service) at the task level.
MULTI-OS COMPATIBILITY:
□ Package and service names are managed through OS family-specific variable files.
□ OS variable loading uses 'include_vars' with dynamic lookup.
□ OS-specific task files are loaded through conditional 'include_tasks'.
□ Avoiding the use of 'when: ansible_os_family' conditions on every task.
TESTING & DOCUMENTATION:
□ Providing a complete README.md file with an input variable list.
□ Providing a 'tests/' directory with a self-contained test playbook.
□ Writing handlers with dynamic names based on service variables.
□ Avoiding direct service restarts in main tasks (always use handlers).
Summary #
- Logic and Data Separation — Reusable roles must separate task logic from parameter data. All project-specific values must be exposed as variables to prevent internal task file modifications.
- Safe Default Variables — Put all optional parameters in
defaults/main.ymlwith safe, sensible default values so the role can be used instantly.- OS-Agnostic Modularity — Avoid platform locking by using the generic
packagemodule and splitting variable configuration files based onansible_os_family.- Dynamic Task Loading — Use the combination of
include_varsandinclude_tasksto dynamically load OS-specific variables and installation tasks at runtime.- Jinja2 Dynamization — Use Jinja2 templates for configuration files to replace static files, complete with
default()andmandatoryfilters for parameter validation.- Single Responsibility Principle — Make sure a role is only responsible for one single service. Don’t mix web server installation with database installation or firewall setup.
- Dynamic & Idempotent Handlers — Define service restart handlers using dynamic service name variables and make sure handlers are safe to run in check mode simulations.
- Independent Testing System — Provide a
tests/folder in the role structure to facilitate isolated testing before the role is used in the organization’s main playbooks.