Introduction

Roles break large playbooks into reusable, self-contained components. Instead of a 500-line playbook, you have roles/nginx/, roles/postgresql/, roles/myapp/ — each with its own tasks, templates, variables, and handlers. Roles are shareable, testable, and the standard way to organize Ansible automation.

Role Directory Structure

roles/
└── webserver/
    ├── tasks/
    │   └── main.yml          # Main task list (auto-loaded)
    ├── handlers/
    │   └── main.yml          # Handlers (auto-loaded)
    ├── templates/
    │   └── nginx.conf.j2     # Jinja2 templates
    ├── files/
    │   └── index.html        # Static files
    ├── vars/
    │   └── main.yml          # High-priority variables
    ├── defaults/
    │   └── main.yml          # Default variables (easily overridden)
    ├── meta/
    │   └── main.yml          # Role metadata and dependencies
    └── README.md
DirectoryPurposeAuto-loaded?
tasks/Main task list✅ main.yml
handlers/Handler definitions✅ main.yml
defaults/Default variables (lowest priority)✅ main.yml
vars/Role variables (high priority)✅ main.yml
templates/Jinja2 templatesReferenced by tasks
files/Static filesReferenced by tasks
meta/Dependencies and metadata✅ main.yml

Create a Role

# Scaffold with ansible-galaxy
ansible-galaxy role init roles/webserver

tasks/main.yml

---
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true

- name: Deploy nginx config
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    validate: "nginx -t -c %s"
  notify: Restart nginx

- name: Deploy site config
  ansible.builtin.template:
    src: site.conf.j2
    dest: "/etc/nginx/sites-available/{{ site_name }}"
  notify: Reload nginx

- name: Enable site
  ansible.builtin.file:
    src: "/etc/nginx/sites-available/{{ site_name }}"
    dest: "/etc/nginx/sites-enabled/{{ site_name }}"
    state: link
  notify: Reload nginx

- name: Ensure nginx is running
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

defaults/main.yml

---
# Easily overridden by playbook vars
site_name: default
http_port: 80
worker_processes: auto
worker_connections: 1024
server_name: "{{ ansible_fqdn }}"
document_root: /var/www/html
ssl_enabled: false

handlers/main.yml

---
- name: Restart nginx
  ansible.builtin.service:
    name: nginx
    state: restarted

- name: Reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded

meta/main.yml

---
galaxy_info:
  author: Your Name
  description: Install and configure Nginx web server
  license: MIT
  min_ansible_version: "2.14"
  platforms:
    - name: Ubuntu
      versions: [jammy, focal]
    - name: Debian
      versions: [bookworm, bullseye]

dependencies:
  - role: common     # Run 'common' role first
  - role: firewall
    vars:
      firewall_allowed_ports:
        - 80
        - 443

Use a Role

In a Playbook

---
- name: Set up web servers
  hosts: webservers
  become: true
  roles:
    # Simple
    - webserver

    # With variables
    - role: webserver
      vars:
        http_port: 8080
        server_name: app.example.com
        ssl_enabled: true

    # With tags and conditions
    - role: monitoring
      tags: [monitoring]
      when: enable_monitoring | default(true)

include_role (Dynamic)

tasks:
  - name: Include role dynamically
    ansible.builtin.include_role:
      name: webserver
    vars:
      site_name: "{{ item }}"
    loop:
      - site-a
      - site-b

  - name: Include only specific tasks
    ansible.builtin.include_role:
      name: webserver
      tasks_from: ssl.yml    # Run tasks/ssl.yml instead of tasks/main.yml

import_role (Static)

tasks:
  - name: Import role statically
    ansible.builtin.import_role:
      name: webserver
    vars:
      http_port: 9090

include_role vs import_role

Featureinclude_roleimport_role
When processedRuntime (dynamic)Parse time (static)
Loop support✅❌
ConditionalPer iterationApplies to all tasks
TagsLimitedFull inheritance
tasks_from✅✅

Variable Precedence

From lowest to highest priority:

1. role defaults/main.yml          ← Easily overridden
2. inventory group_vars
3. inventory host_vars
4. playbook vars
5. role vars/main.yml              ← Hard to override
6. extra vars (-e)                 ← Always wins

Use defaults/ for values users should customize. Use vars/ for internal constants that shouldn't change.

Multiple Task Files

Split large roles into multiple files:

roles/webserver/tasks/
├── main.yml       # Entry point
├── install.yml
├── configure.yml
├── ssl.yml
└── firewall.yml

tasks/main.yml:

---
- name: Install packages
  ansible.builtin.include_tasks: install.yml

- name: Configure web server
  ansible.builtin.include_tasks: configure.yml

- name: Configure SSL
  ansible.builtin.include_tasks: ssl.yml
  when: ssl_enabled

- name: Configure firewall
  ansible.builtin.include_tasks: firewall.yml
  when: manage_firewall | default(true)

Role Dependencies

meta/main.yml:

dependencies:
  - role: common
  - role: firewall
    vars:
      firewall_ports: [80, 443]
  - role: certbot
    when: ssl_enabled

Dependencies run before the role's own tasks. If multiple roles depend on the same role, it runs only once (unless allow_duplicates: true).

Complete Example: Application Role

roles/myapp/
├── defaults/main.yml
├── tasks/main.yml
├── handlers/main.yml
├── templates/
│   ├── config.yml.j2
│   └── systemd.service.j2
├── files/
│   └── logrotate.conf
└── meta/main.yml

defaults/main.yml:

---
myapp_version: "latest"
myapp_port: 8080
myapp_user: myapp
myapp_group: myapp
myapp_dir: /opt/myapp
myapp_log_dir: /var/log/myapp
myapp_env: production

tasks/main.yml:

---
- name: Create application group
  ansible.builtin.group:
    name: "{{ myapp_group }}"
    system: true

- name: Create application user
  ansible.builtin.user:
    name: "{{ myapp_user }}"
    group: "{{ myapp_group }}"
    system: true
    shell: /usr/sbin/nologin
    home: "{{ myapp_dir }}"

- name: Create directories
  ansible.builtin.file:
    path: "{{ item }}"
    state: directory
    owner: "{{ myapp_user }}"
    group: "{{ myapp_group }}"
    mode: "0755"
  loop:
    - "{{ myapp_dir }}"
    - "{{ myapp_dir }}/bin"
    - "{{ myapp_log_dir }}"

- name: Deploy configuration
  ansible.builtin.template:
    src: config.yml.j2
    dest: "{{ myapp_dir }}/config.yml"
    owner: "{{ myapp_user }}"
    mode: "0640"
  notify: Restart myapp

- name: Deploy systemd service
  ansible.builtin.template:
    src: systemd.service.j2
    dest: /etc/systemd/system/myapp.service
  notify:
    - Reload systemd
    - Restart myapp

- name: Enable and start service
  ansible.builtin.service:
    name: myapp
    state: started
    enabled: true

handlers/main.yml:

---
- name: Reload systemd
  ansible.builtin.systemd:
    daemon_reload: true

- name: Restart myapp
  ansible.builtin.service:
    name: myapp
    state: restarted

Playbook using this role:

---
- name: Deploy application
  hosts: appservers
  become: true
  roles:
    - role: myapp
      vars:
        myapp_version: "2.1.0"
        myapp_port: 9090
        myapp_env: staging

Troubleshooting

"the role 'x' was not found"

Check role search paths:

# Default paths
# ./roles/
# ~/.ansible/roles/
# /etc/ansible/roles/

# Override in ansible.cfg
# [defaults]
# roles_path = ./roles:~/.ansible/roles

Handler Not Triggering

Handler names are case-sensitive and must match exactly between notify: and the handler's name:.

Conclusion

Roles are the standard way to organize Ansible code. Put default variables in defaults/, internal constants in vars/, and split large task lists with include_tasks. Use meta/main.yml for dependencies so prerequisite roles run automatically. The key insight: defaults/ variables are meant to be overridden by users, while vars/ variables are internal to the role. Share roles via Ansible Galaxy or private Git repos, and always include a README.md documenting available variables.