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, dependencies

Generate a skeleton with:

ansible-galaxy role init webserver

Only 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:

  1. defaults/main.yml — lowest priority. These are the values users are expected to override.
  2. Inventory variables (group_vars/, host_vars/)
  3. vars/main.yml — higher than inventory. These are internal to the role and should rarely be overridden.
  4. Playbook vars: block
  5. --extra-vars on 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/html

Put computed or internal values in vars/:

# vars/main.yml
webserver_packages:
  - nginx
  - openssl
webserver_config_path: /etc/nginx/nginx.conf

Users 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: restarted

Even 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: 30

Be 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.yml

include_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 defined

Use import_role by default for predictability. Use include_role when you need runtime decisions.

Common Mistakes#

  • Secrets in vars/main.yml in plaintext. Use ansible-vault encrypt_string for individual values or encrypt the entire file with ansible-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 command or shell when a module exists. command: apt-get install nginx skips idempotency. Use the apt module instead.