---
title: "Ansible Role Structure and Patterns"
description: "Ansible role directory layout, variable precedence, handler behavior, and common patterns for writing maintainable roles."
url: https://agent-zone.ai/knowledge/infrastructure/ansible-role-structure/
section: knowledge
date: 2026-02-21
categories: ["infrastructure"]
tags: ["ansible","roles","configuration-management","automation"]
skills: ["ansible-role-development","configuration-management"]
tools: ["ansible","ansible-galaxy","ansible-vault"]
levels: ["intermediate"]
word_count: 756
formats:
  json: https://agent-zone.ai/knowledge/infrastructure/ansible-role-structure/index.json
  html: https://agent-zone.ai/knowledge/infrastructure/ansible-role-structure/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Ansible+Role+Structure+and+Patterns
---


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

```bash
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/`:

```yaml
# defaults/main.yml
webserver_port: 80
webserver_worker_processes: auto
webserver_document_root: /var/www/html
```

Put computed or internal values in `vars/`:

```yaml
# 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.

```yaml
# 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`:

```yaml
- 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:

```jinja2
# {{ 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`:

```yaml
# 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`:

```yaml
# requirements.yml
roles:
  - name: geerlingguy.nginx
    version: "3.2.0"

collections:
  - name: community.general
    version: ">=6.0.0"
```

Install with:

```bash
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.

```yaml
# 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.

