Standard Role Directory Structure#
An Ansible role is a directory with a fixed layout. Each subdirectory serves a specific purpose:
roles/
webserver/
tasks/
main.yml # Entry point — task list
handlers/
main.yml # Service restart/reload triggers
templates/
nginx.conf.j2 # Jinja2 templates
files/
index.html # Static files copied as-is
vars/
main.yml # Internal variables (high precedence)
defaults/
main.yml # Default variables (low precedence, meant to be overridden)
meta/
main.yml # Role metadata, dependenciesGenerate a skeleton with:
ansible-galaxy role init webserverOnly the directories you need must exist. An empty vars/ directory is unnecessary and adds noise.
Variable Precedence#
Ansible has 22 levels of variable precedence. In practice, you need to know these five:
defaults/main.yml— lowest priority. These are the values users are expected to override.- Inventory variables (
group_vars/,host_vars/) vars/main.yml— higher than inventory. These are internal to the role and should rarely be overridden.- Playbook
vars:block --extra-varson the command line — highest priority, overrides everything.
The defaults/ vs vars/ distinction matters. Put configuration knobs in defaults/:
# defaults/main.yml
webserver_port: 80
webserver_worker_processes: auto
webserver_document_root: /var/www/htmlPut computed or internal values in vars/:
# vars/main.yml
webserver_packages:
- nginx
- openssl
webserver_config_path: /etc/nginx/nginx.confUsers override webserver_port in their inventory or playbook. Nobody should override webserver_config_path — that is an implementation detail of the role.
Handler Patterns#
Handlers are tasks that run only when notified, and only once at the end of the play, regardless of how many tasks notify them.
# tasks/main.yml
- name: Install nginx configuration
template:
src: nginx.conf.j2
dest: "{{ webserver_config_path }}"
notify: restart nginx
- name: Install SSL certificate
copy:
src: "{{ ssl_cert_file }}"
dest: /etc/nginx/ssl/server.crt
notify: restart nginx
# handlers/main.yml
- name: restart nginx
service:
name: nginx
state: restartedEven if both tasks change, restart nginx runs once at the end — not twice.
If you need a handler to fire immediately (for example, a service must restart before a later task that depends on it), use meta: flush_handlers:
- name: Install nginx configuration
template:
src: nginx.conf.j2
dest: "{{ webserver_config_path }}"
notify: restart nginx
- meta: flush_handlers
- name: Wait for nginx to accept connections
wait_for:
port: "{{ webserver_port }}"
timeout: 30Be aware that flush_handlers runs all pending handlers, not just the one you care about. If multiple handlers are queued, they all fire at that point.
Template Patterns#
Templates use Jinja2 and should include a managed-file header so nobody hand-edits a generated file:
# {{ ansible_managed }}
# Do not edit this file manually.
worker_processes {{ webserver_worker_processes }};
events {
worker_connections 1024;
}
http {
server {
listen {{ webserver_port }};
root {{ webserver_document_root }};
}
}The {{ ansible_managed }} variable expands to a string like Ansible managed: /path/to/template.j2 modified on 2026-02-21 — a clear signal to anyone reading the file on the target host.
Role Dependencies and Collections#
Declare dependencies in meta/main.yml:
# meta/main.yml
dependencies:
- role: common
- role: firewall
vars:
firewall_allowed_ports:
- "{{ webserver_port }}"Dependencies execute before the role’s own tasks. By default, a role only runs once per play even if listed as a dependency multiple times. Set allow_duplicates: true in meta/main.yml if the role must run multiple times with different variables.
For third-party roles, use a requirements.yml:
# requirements.yml
roles:
- name: geerlingguy.nginx
version: "3.2.0"
collections:
- name: community.general
version: ">=6.0.0"Install with:
ansible-galaxy install -r requirements.yml
ansible-galaxy collection install -r requirements.ymlinclude_role vs import_role#
import_role is static — Ansible processes it at parse time. All tasks appear in the play as if written inline. Tags and when conditions apply to every task in the role.
include_role is dynamic — Ansible processes it at runtime. The role’s tasks are not visible until execution reaches that point. This lets you conditionally include roles based on facts gathered during the play.
# Static — parsed upfront, all tasks visible to --list-tasks
- import_role:
name: webserver
# Dynamic — evaluated at runtime, can use runtime variables
- include_role:
name: "{{ platform_role }}"
when: platform_role is definedUse import_role by default for predictability. Use include_role when you need runtime decisions.
Common Mistakes#
- Secrets in
vars/main.ymlin plaintext. Useansible-vault encrypt_stringfor individual values or encrypt the entire file withansible-vault encrypt vars/secrets.yml. - Missing
become: true. Tasks that install packages or modify system files need privilege escalation. Set it at the play level or per-task, but do not forget it. - Assuming handler order. Handlers run in the order they are defined in
handlers/main.yml, not in the order they are notified. If ordering matters, define them in the right sequence. - Using
commandorshellwhen a module exists.command: apt-get install nginxskips idempotency. Use theaptmodule instead.