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
| Directory | Purpose | Auto-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 templates | Referenced by tasks |
files/ | Static files | Referenced 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
| Feature | include_role | import_role |
|---|---|---|
| When processed | Runtime (dynamic) | Parse time (static) |
| Loop support | ✅ | ❌ |
| Conditional | Per iteration | Applies to all tasks |
| Tags | Limited | Full 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:.
Related Articles
- Ansible Galaxy — Roles & Collections
- Ansible Handlers
- Ansible Variables Guide
- Ansible Best Practices
- Ansible FQCN Guide
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.