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:

  1. Low Readability: If you have 100 tasks with complex configuration logic for each service, this file becomes very long and exhausting to read.
  2. 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.
  3. 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:

  1. tasks/: This directory contains YAML files defining the main tasks the role will execute. The tasks/main.yml file is the mandatory entry point Ansible reads first.
  2. defaults/: This directory stores role default variables in the main.yml file. 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.
  3. vars/: Unlike defaults/, this directory stores role internal variables in the main.yml file with high priority. You should use this directory for constants or values that role users shouldn’t accidentally change.
  4. 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.
  5. 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.
  6. handlers/: Stores handler definitions in the main.yml file. Handlers here react to state changes (for example, restarting the Nginx service after a configuration file changes).
  7. meta/: Stores metadata about the role in the main.yml file, like the author name, license, supported operating systems, and dependencies on other roles that must run first.
  8. tests/: Contains a minimal playbook and inventory used to test role functionality in isolation (for example via CI/CD or Molecule).
  9. 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.yml file 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:

LevelVariable SourceCustomization LevelDescription
1role defaultsLowestDefined in defaults/main.yml
2inventory group_varsLowSet in inventory group_vars
3inventory host_varsLowSet in inventory host_vars
4playbook group_varsMediumSet in playbook group_vars
5playbook host_varsMediumSet in playbook host_vars
6host facts / cached factsRigidObtained from the target system during gathering facts
7play varsHighSet in the vars: section of a play
8play vars_promptHighInteractively input by the user
9play vars_filesHighRead from external files in a play
10role varsVery HighDefined in vars/main.yml
11block varsVery HighSet at the block task level
12task varsVery HighSet at the individual task level
13extra vars (-e)HighestInput 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 Aspectroles: Blockimport_roleinclude_role
Execution TypeStatic (Compile Time)Static (Compile Time)Dynamic (Runtime)
Declaration LocationPlay LevelTasks LevelTasks Level
Running OrderBefore any tasksAccording to task orderAccording to task order
Supports loopNoNoYes
when BehaviorApplied to each taskApplied to each taskEvaluated once for the entire role
Handlers SupportImported directlyImported directlyImported at runtime
Performance ImpactVery fastVery fastSlight 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.yml and configure.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), and include_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.

← Previous: Debugging Next: Structure →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact