What is a Role? #
When you first learn Ansible, you usually start by writing one or two simple playbook files. These playbooks contain a list of target hosts and a series of tasks executed in sequence. This approach is great for simple scenarios or quick trials. However, as the infrastructure you manage grows, your playbooks start growing exponentially. A playbook file that originally had just 20 lines can quickly balloon into hundreds or even thousands of lines of code covering web server configuration, database installation, firewall setup, and user management.
This monolithic playbook condition causes various serious problems. The code becomes very hard to read, maintenance becomes a nightmare because small changes in one part can unexpectedly break other parts, and most crucially, you can’t share or reuse that automation logic for other projects without error-prone copy-paste operations. To solve these scalability and modularity problems, Ansible introduced the Role concept.
A role is a structured way to package Ansible automation content so it can be reused modularly. Using roles, you can separate playbooks from configuration files, variables, static files, templates, and handlers. Instead of writing a giant all-purpose playbook, you split the logic into small, independent units with single responsibilities, like a dedicated role for configuring Nginx, PostgreSQL, or Docker.
The Monolithic Playbook Problem in the Real World #
Let’s look at a real scenario. Imagine you’re tasked with configuring an integrated web server requiring Nginx, PHP, and PostgreSQL. Without roles, you might be tempted to write a monolithic playbook like this:
# monolithic_playbook.yml
# ANTI-PATTERN: Combining all services in one giant playbook file
- name: Set up the Web Server and Database
hosts: webservers
become: true
vars:
nginx_port: 80
db_name: app_production
db_user: admin_user
tasks:
# --- NGINX INSTALLATION TASKS ---
- name: Install Nginx
apt:
name: nginx
state: present
update_cache: true
- name: Copy Nginx Configuration
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart Nginx
# --- POSTGRESQL INSTALLATION TASKS ---
- name: Install PostgreSQL
apt:
name: postgresql
state: present
- name: Ensure PostgreSQL Is Running
service:
name: postgresql
state: started
enabled: true
# --- APPLICATION DEPLOYMENT TASKS ---
- name: Download the Application Source Code
git:
repo: 'https://github.com/example/repo.git'
dest: /var/www/html
version: master
handlers:
- name: Restart Nginx
service:
name: nginx
state: restarted
The playbook above has several critical weaknesses:
- Low Readability: If you have 100 tasks with complex configuration logic for each service, this file becomes very long and exhausting to read.
- No Reusability: If later you want to create a new database server without a web server, you can’t reuse the PostgreSQL code in this playbook without cutting it out or creating a new file.
- Mixed Responsibilities: Variables, tasks, and handlers for various different services are mixed into one global namespace.
Now, let’s compare that with the modular approach using roles. By separating each component into its own role, you can write a main playbook (site.yml) that’s very clean, structured, and understandable within seconds:
# site.yml
# CORRECT: Using roles to separate responsibilities modularly
- name: Set up the Web Server and Database
hosts: webservers
become: true
roles:
- role: common # Handles base OS setup and security
- role: postgresql # Handles database installation and configuration
- role: nginx # Handles web server installation and configuration
- role: myapp # Handles our application code deployment
In this approach, the implementation details of each component are hidden inside their respective role directories. Your playbook now only acts as a high-level declaration of which services you want to apply to a particular host group.
Understanding the Role Directory Structure Anatomy #
The main power of roles lies in their directory structure convention. Ansible applies strict yet flexible rules about where files are stored inside a role. When you call a role in a playbook, Ansible automatically looks for files, templates, variables, and handlers in specific subdirectories based on the role name.
Let’s study the standard directory structure chart of a role named nginx:
roles/nginx/
├── tasks/
│ ├── main.yml # Main entry point for defining tasks
│ ├── install.yml # Sub-task specifically for package installation
│ └── configure.yml # Sub-task specifically for configuration file setup
├── defaults/
│ └── main.yml # Default variables with the lowest priority
├── vars/
│ └── main.yml # Internal variables with high priority
├── templates/
│ └── nginx.conf.j2 # Jinja2 template processed by Ansible
├── files/
│ └── index.html # Static file copied directly to the node
├── handlers/
│ └── main.yml # Handler definitions triggered by tasks in this role
├── meta/
│ └── main.yml # Role metadata, author, platform, and dependencies
├── tests/
│ ├── inventory # Inventory file for local testing purposes
│ └── test.yml # Test playbook for running the role locally
├── library/
│ └── custom_module.py # Role-specific custom Python module (optional)
├── lookup_plugins/
│ └── custom_lookup.py # Role-specific custom lookup plugin (optional)
└── README.md # Complete documentation on role usage
Let’s break down the function of each important subdirectory above:
tasks/: This directory contains YAML files defining the main tasks the role will execute. Thetasks/main.ymlfile is the mandatory entry point Ansible reads first.defaults/: This directory stores role default variables in themain.ymlfile. Variables here have the lowest priority in the Ansible variable hierarchy, making it very easy for role users to override their values from outside the role.vars/: Unlikedefaults/, this directory stores role internal variables in themain.ymlfile with high priority. You should use this directory for constants or values that role users shouldn’t accidentally change.templates/: This directory stores Jinja2 template files (usually ending in.j2). These templates can read Ansible variables and get translated into dynamic configuration files before being sent to managed nodes.files/: Contains static files you want to copy to managed nodes as-is without variable rendering. Examples are image files, static SSL certificates, or helper bash scripts.handlers/: Stores handler definitions in themain.ymlfile. Handlers here react to state changes (for example, restarting the Nginx service after a configuration file changes).meta/: Stores metadata about the role in themain.ymlfile, like the author name, license, supported operating systems, and dependencies on other roles that must run first.tests/: Contains a minimal playbook and inventory used to test role functionality in isolation (for example via CI/CD or Molecule).library/&lookup_plugins/: Special directories for including custom modules or additional plugins written in Python. This lets your role carry its own custom code without installing it globally on the control node.
It’s important to note that you don’t have to create all the directories above. Ansible ignores empty or missing directories. A very simple role could only have a tasks/ directory with a single main.yml file inside.
tasks/main.yml as the Orchestration Center and Entry Point #
Every time Ansible executes a role, the first file it looks for and executes inside the tasks/ directory is main.yml. Writing all tasks directly in the main.yml file is common for small roles. However, if your role has dozens of tasks with various conditional logic, the main.yml file becomes very long and hard to read.
The best practice we apply is using tasks/main.yml as a clean orchestration center (controller). You split tasks into several YAML files by logical function, then reassemble them in tasks/main.yml using import_tasks or include_tasks.
Let’s look at an example of clean task orchestration implementation in the nginx role:
# roles/nginx/tasks/main.yml
# CORRECT: Using tasks/main.yml as the task orchestrator
---
- name: Run OS prerequisite preparation
import_tasks: prerequisites.yml
tags: nginx_prereqs
- name: Run the nginx package installation
import_tasks: install.yml
tags: nginx_install
- name: Run the nginx file configuration
import_tasks: configure.yml
tags: nginx_configure
- name: Run the SSL/TLS security setup
include_tasks: ssl.yml
when: nginx_ssl_enabled | default(false)
tags: nginx_ssl
With tasks separated like this, let’s see how the contents of each supporting task file stay focused on a single responsibility:
# roles/nginx/tasks/prerequisites.yml
---
- name: Add the official Nginx repository
apt_repository:
repo: "deb https://nginx.org/packages/mainline/ubuntu/ {{ ansible_distribution_release }} nginx"
state: present
update_cache: true
- name: Create the nginx user group
group:
name: "{{ nginx_group }}"
state: present
# roles/nginx/tasks/install.yml
---
- name: Install the nginx package through the package manager
apt:
name: nginx
state: present
- name: Ensure the nginx service is enabled at boot
service:
name: nginx
state: started
enabled: true
This orchestration strategy provides several advantages:
- Fast Code Navigation: Other developers can immediately understand the role workflow just by reading
tasks/main.yml. - Efficient Debugging: If an error occurs at the configuration stage, you can directly focus on checking the
configure.ymlfile without being distracted by installation or SSL setup code. - Targeted Tagging: You can apply tags at the import file level in
tasks/main.yml, making it easy to run only a specific task subset from the command line.
Deep Analysis: defaults/main.yml vs vars/main.yml #
One concept that most often confuses new Ansible users is the difference between the defaults/ and vars/ directories. Both directories are used to store variables in the main.yml file, but they serve very different purposes and have far different precedence in the Ansible engine.
Let’s discuss this fundamental difference in detail:
1. defaults/main.yml (Default Variables) #
Variables defined in defaults/main.yml have the lowest priority (level 2 of the 22 priority levels in Ansible). This means variables here are designed as safe basic default values, with the assumption that role users will very often change their values.
Appropriate usage examples for defaults/main.yml are:
- Service ports (for example,
nginx_port: 80). - Default process running users (for example,
nginx_user: www-data). - Commonly changed performance parameters (for example,
nginx_keepalive_timeout: 65).
2. vars/main.yml (Internal Variables/Constants) #
Variables defined in vars/main.yml have high priority (level 15 in Ansible). Values here override variables from inventory, host vars, group vars, and defaults. These variables are intended as internal role constants that outside users shouldn’t change, because changes to them could break the role’s internal operation.
Appropriate usage examples for vars/main.yml are:
- OS-specific configuration paths (for example,
nginx_config_dir: /etc/nginx). - Lists of dependency packages needed by the target OS.
- Static system service names.
Let’s look at a direct code-side comparison to avoid fatal mistakes:
# roles/nginx/defaults/main.yml
# CORRECT: Default variables customizable by our playbook users
---
nginx_http_port: 80
nginx_max_upload_size: "16m"
nginx_enable_gzip: true
# roles/nginx/vars/main.yml
# CORRECT: Internal role constants that users shouldn't tamper with
---
nginx_config_path: "/etc/nginx/nginx.conf"
nginx_mime_types_path: "/etc/nginx/mime.types"
nginx_systemd_service_name: "nginx"
To clarify the priority level differences, let’s look at the Ansible variable priority hierarchy table (from lowest to highest priority) to understand both positions:
| Level | Variable Source | Customization Level | Description |
|---|---|---|---|
| 1 | role defaults | Lowest | Defined in defaults/main.yml |
| 2 | inventory group_vars | Low | Set in inventory group_vars |
| 3 | inventory host_vars | Low | Set in inventory host_vars |
| 4 | playbook group_vars | Medium | Set in playbook group_vars |
| 5 | playbook host_vars | Medium | Set in playbook host_vars |
| 6 | host facts / cached facts | Rigid | Obtained from the target system during gathering facts |
| 7 | play vars | High | Set in the vars: section of a play |
| 8 | play vars_prompt | High | Interactively input by the user |
| 9 | play vars_files | High | Read from external files in a play |
| 10 | role vars | Very High | Defined in vars/main.yml |
| 11 | block vars | Very High | Set at the block task level |
| 12 | task vars | Very High | Set at the individual task level |
| 13 | extra vars (-e) | Highest | Input via command line (always wins) |
If you make the mistake of placing frequently changed variables (like nginx_port) into vars/main.yml, your playbook users can’t change that port through their inventory group vars because vars/main.yml always wins and overrides it. This is an anti-pattern you must avoid.
Role Invocation Methods: roles Block, import_role, and include_role #
Ansible provides three different ways to call and execute roles in your playbook. Understanding when to use each method is essential for precisely controlling playbook execution flow.
Let’s discuss the three methods:
1. The roles Section at the Play Level (Classic)
#
This is the most classic and most commonly used method. You declare roles directly under the play. Roles called this way execute before any tasks in that play run.
# playbook.yml
- name: Set up the nginx server
hosts: webservers
roles:
- common
- nginx
Characteristics: Static. All roles are processed when Ansible first compiles the playbook. You can’t use dynamic when conditionals at this role level (conditionals attached here are only statically applied to every task inside the role).
2. The import_role Module (Static at Task Level)
#
The import_role module lets you call a role inside your playbook’s tasks list. It’s static, meaning the role is imported and merged into the playbook at compile time, before tasks start running.
# playbook.yml
- name: Set up the nginx server using import_role
hosts: webservers
tasks:
- name: Run the base setup
import_role:
name: common
- name: Run the nginx setup statically
import_role:
name: nginx
tags: nginx_tasks
Characteristics: Because it’s static, the variables you evaluate to determine the role name must already be available at compile time. Handlers from the imported role are immediately available for triggering by other tasks outside the role.
3. The include_role Module (Dynamic at Task Level)
#
The include_role module executes the role dynamically while the playbook runs (runtime). Ansible doesn’t process the role contents until that include_role task actually executes.
# playbook.yml
- name: Set up the nginx server using include_role dynamically
hosts: webservers
tasks:
- name: Install the database if the host is a db server
include_role:
name: postgresql
when: is_database_host | default(false)
- name: Run the nginx role in a loop for several applications
include_role:
name: nginx
vars:
app_domain: "{{ item }}"
loop:
- "app1.example.com"
- "app2.example.com"
Characteristics: Very flexible. You can use a loop to execute the role repeatedly with different variables, or use a when conditional to determine whether the role needs loading based on facts obtained at runtime.
Let’s create a comprehensive comparison table to make choosing the right method easier:
| Comparison Aspect | roles: Block | import_role | include_role |
|---|---|---|---|
| Execution Type | Static (Compile Time) | Static (Compile Time) | Dynamic (Runtime) |
| Declaration Location | Play Level | Tasks Level | Tasks Level |
| Running Order | Before any tasks | According to task order | According to task order |
Supports loop | No | No | Yes |
when Behavior | Applied to each task | Applied to each task | Evaluated once for the entire role |
| Handlers Support | Imported directly | Imported directly | Imported at runtime |
| Performance Impact | Very fast | Very fast | Slight parsing overhead |
Role Invocation Method Decision Flow Diagram #
To provide a clear visual guide when programming playbooks, here’s the decision flow for choosing the right role invocation method:
flowchart TD
A{"Do you want to call the role inside the 'tasks' list?"} -- "No" --> B["Use the 'roles:' block at the Play level"]
A -- "Yes" --> C{"Do you need a loop or a dynamic role name from runtime variables?"}
C -- "Yes" --> D["Use 'include_role' (Dynamic)"]
C -- "No" --> E{"Do you need a 'when' conditional evaluating the whole role at once?"}
E -- "Yes" --> D
E -- "No" --> F["Use 'import_role' (Static)"]Criteria and Decision-Making: When to Create a Role? #
One common mistake made when starting to like the modularity concept is wrapping every small task group into a separate role. This causes “over-engineering” that makes your infrastructure too complex with dozens of role directories each containing just 2 or 3 simple tasks.
To maintain a balance between modularity and simplicity, you must apply strict evaluation criteria. You should ask yourself whether an automation logic deserves being extracted into its own role.
1. Role Creation Eligibility Checklist #
You’re recommended to create a new role if it meets one or more of the following conditions:
- High Reusability Potential: The automation logic you’re writing will be used in more than one playbook or across several different infrastructure projects.
- Logic Complexity: The task collection has high complexity (more than 15-20 tasks) with many supporting template and handler files.
- Ownership Isolation: The infrastructure component is managed by different teams. For example, the Security team manages the OS hardening role (
os-hardening), while the App Dev team manages the application deployment role. - Standalone Version Control Needs: The logic needs versioning separately (for example stored in its own Git repository and installed using Ansible Galaxy).
2. When to Refuse Creating a Role? #
Conversely, you don’t need to create a role if:
- The task is very specific to one playbook and will never be used elsewhere (just write it in the main playbook or use a regular task file with
include_tasks). - The logic is very simple, for example only consisting of 3 basic installation tasks without custom variables or templates.
- The task is an integral part of the main playbook workflow that can’t be separated without breaking the overall workflow understanding.
To make this decision process easier, you can refer to the structured decision tree below:
flowchart TD
Q1{"Will this automation code be reused in other projects?"} -- "Yes" --> CreateRole["Create an Independent Role"]
Q1 -- "No" --> Q2{"Is the task count very large (>15 tasks) or the configuration complex?"}
Q2 -- "Yes" --> CreateRole
Q2 -- "No" --> Q3{"Is there a development team ownership separation?"}
Q3 -- "Yes" --> CreateRole
Q3 -- "No" --> Q4{"Do we need easily overridable default variables flexibly?"}
Q4 -- "Yes" --> CreateRole
Q4 -- "No" --> KeepPlaybook["Use a Regular Playbook or a regular task file with 'include_tasks'"]By following the decision guide above, you can ensure your Ansible repository structure stays clean, efficient, easy to manage, and not cluttered with unnecessary small roles.
Summary #
- Roles are reusable modular units in Ansible that wrap tasks, vars, defaults, templates, files, and handlers into one standard directory convention.
- tasks/main.yml acts as the main entry point and orchestrator delegating execution flow to sub-task files like
install.ymlandconfigure.yml.- defaults/main.yml is used for default variables with the lowest priority that are easy to override, while vars/main.yml stores role internal constants with high priority.
- Role invocation methods consist of the
roles:block (classic static),import_role(static at task level), andinclude_role(dynamic at runtime supporting loops).- Create a new role only when the logic has high reusability potential, high complexity, or needs standalone version control management.