Ansible Roles in Practice

How a real role is laid out on disk, what each directory does, and how to write and call one.

What a role is and why it exists

A role is a self-contained unit of Ansible automation. Instead of writing one giant playbook with hundreds of tasks, you split your work into roles — each one responsible for managing one service or concern.

Roles give you:

If a colleague hands you an Ansible repo and says "look at the nginx role", you already know exactly where to find the config template, the restart handler, and the default variables — because every role follows the same layout.

Role directory layout

roles/ └── chrony_client/ ├── defaults/ │ └── main.yml # default variable values (lowest priority) ├── vars/ │ └── main.yml # role-internal vars (override group_vars) ├── tasks/ │ ├── main.yml # entry point — calls sub-files │ ├── install.yml # package installation tasks │ ├── config.yml # template rendering tasks │ ├── service.yml # service management tasks │ └── verify.yml # post-deploy verification tasks ├── handlers/ │ └── main.yml # restart/reload handlers ├── templates/ │ └── chrony.conf.j2 # Jinja2 config template ├── files/ │ └── chrony-keys # static files to copy as-is └── meta/ └── main.yml # role metadata and dependencies

Not every directory needs to exist. Ansible only looks for what is present. A minimal role might just have tasks/main.yml.

defaults/main.yml

defaults/main.yml holds the role's default variable values. These are the lowest priority variables in Ansible — anything in group_vars, host_vars, or passed with -e overrides them.

Use defaults for every variable the role uses. This documents what the role accepts and ensures it works even when the caller provides nothing.

---
# defaults/main.yml for chrony_client role

# List of NTP servers to sync from
chrony_servers:
  - 0.pool.ntp.org
  - 1.pool.ntp.org

# Allow stepping the clock during first 3 syncs if offset > 1 second
chrony_makestep: "1.0 3"

# Sync the hardware clock
chrony_rtcsync: true
Rule of thumb: If a variable has a sensible default that works in most cases, put it in defaults/. If it is dangerous to guess (like a hostname or password), leave it undefined so Ansible will error loudly if the caller forgets to provide it.

vars/main.yml

vars/main.yml holds variables that are internal to the role and should not be overridden from outside. These have higher priority than group_vars.

Use vars/ for things like service names, package names, or paths that the role controls internally:

---
# vars/main.yml for chrony_client role

_chrony_package: chrony
_chrony_service: chronyd
_chrony_config_path: /etc/chrony.conf

The leading underscore is a convention indicating "internal variable — do not override from outside". It is not enforced by Ansible but is widely used.

defaults/ vs vars/: Most variables should go in defaults/, not vars/. Only put things in vars/ when you specifically do not want users to be able to override them.

tasks/main.yml and sub-task files

tasks/main.yml is the entry point — the first file Ansible reads when it applies the role. Rather than listing all tasks here, keep it thin and use it to include sub-files:

---
# tasks/main.yml

- name: Include install tasks
  ansible.builtin.include_tasks: install.yml
  tags: [chrony, install]

- name: Include config tasks
  ansible.builtin.include_tasks: config.yml
  tags: [chrony, config]

- name: Include service tasks
  ansible.builtin.include_tasks: service.yml
  tags: [chrony, service]

- name: Include verify tasks
  ansible.builtin.include_tasks: verify.yml
  tags: [chrony, verify]

Each sub-file focuses on one concern:

---
# tasks/install.yml

- name: Install chrony package
  ansible.builtin.package:
    name: "{{ _chrony_package }}"
    state: present
---
# tasks/config.yml

- name: Deploy chrony config
  ansible.builtin.template:
    src: chrony.conf.j2
    dest: "{{ _chrony_config_path }}"
    owner: root
    group: root
    mode: '0644'
    validate: chronyd -Q -f %s
  notify: Restart chronyd
---
# tasks/service.yml

- name: Enable and start chronyd
  ansible.builtin.service:
    name: "{{ _chrony_service }}"
    state: started
    enabled: true
---
# tasks/verify.yml

- name: Check chrony tracking
  ansible.builtin.command: chronyc tracking
  register: chrony_tracking
  changed_when: false

- name: Show tracking output
  ansible.builtin.debug:
    var: chrony_tracking.stdout_lines

handlers/main.yml

Handlers run only when notified by a task that reported a change. They are typically used for service restarts after a config file changes.

---
# handlers/main.yml

- name: Restart chronyd
  ansible.builtin.service:
    name: "{{ _chrony_service }}"
    state: restarted

- name: Reload chronyd
  ansible.builtin.service:
    name: "{{ _chrony_service }}"
    state: reloaded

Handlers run at the end of the play, not immediately when notified. If a config task changes the file and notifies "Restart chronyd", the restart happens once after all tasks complete — even if 5 tasks notified the same handler.

templates/

Template files end in .j2 and are rendered by Ansible using Jinja2 before being deployed. The rendered output is the actual config file that lands on the server.

# templates/chrony.conf.j2

{% for server in chrony_servers %}
server {{ server }} iburst
{% endfor %}

{% if chrony_pools is defined %}
{% for pool in chrony_pools %}
pool {{ pool }} iburst
{% endfor %}
{% endif %}

driftfile /var/lib/chrony/drift
makestep {{ chrony_makestep }}
{% if chrony_rtcsync %}
rtcsync
{% endif %}

Variables referenced in templates come from the same variable resolution chain as tasks — defaults, group_vars, host_vars, etc. The template module renders the file on the Ansible controller and uploads the result to the target host.

files/

Static files that should be copied as-is (no templating). Use the copy module to deploy them:

- name: Copy static config fragment
  ansible.builtin.copy:
    src: chrony-keys        # looks in roles/chrony_client/files/
    dest: /etc/chrony.keys
    mode: '0640'

When you use src: filename in the copy module, Ansible automatically searches files/ in the role directory. You do not need to specify the full path.

meta/main.yml

Role metadata — primarily used for documenting dependencies on other roles:

---
# meta/main.yml

galaxy_info:
  role_name: chrony_client
  author: your_name
  description: Configures Chrony NTP client
  min_ansible_version: "2.12"
  platforms:
    - name: EL
      versions: ["8", "9"]

dependencies:
  - role: common
    vars:
      common_packages_extra: [chrony]

If a role lists dependencies, Ansible runs those roles first automatically. Only use this for true prerequisites — do not use it to chain unrelated roles.

Calling a role from a playbook

There are three ways to use a role in a playbook:

In the roles: section (preferred)

---
- name: Configure NTP clients
  hosts: all
  become: true
  roles:
    - role: chrony_client
      vars:
        chrony_servers:
          - ntp1.internal.example.com
          - ntp2.internal.example.com

With import_role (static — resolved at parse time)

- name: Apply chrony role
  ansible.builtin.import_role:
    name: chrony_client

With include_role (dynamic — resolved at runtime)

- name: Apply chrony role on RHEL hosts only
  ansible.builtin.include_role:
    name: chrony_client
  when: ansible_os_family == "RedHat"

Fully qualified collection name (FQCN)

roles:
  - role: your_namespace.your_collection.chrony_client

Real example: chrony role in action

What happens when you run ansible-playbook site.yml --tags chrony:

  1. Ansible reads the inventory and connects to all target hosts
  2. It finds the chrony_client role in the roles/ directory
  3. It loads defaults/main.yml — the NTP server defaults are available
  4. It checks group_vars — if chrony_servers is defined there, it overrides the defaults
  5. It runs tasks/main.yml, which calls install → config → service → verify
  6. The config task renders chrony.conf.j2 with the resolved variables
  7. If the rendered file differs from what is on disk, the template task reports "changed" and notifies "Restart chronyd"
  8. After all tasks complete, Ansible runs the handler — chronyd restarts
  9. The verify task runs chronyc tracking to confirm sync is working

Generating a role skeleton

ansible-galaxy role init chrony_client

Creates the full directory structure with empty placeholder files. Faster than creating them by hand. Run this from inside your roles/ directory.

Installing third-party roles

Community roles from Ansible Galaxy (or private Git repos) are declared in roles/requirements.yml and installed before any playbook run.

roles/requirements.yml format

---
# From Ansible Galaxy
- name: geerlingguy.ntp
  version: "2.3.2"          # pin to a specific version

# From a private Git repository
- name: internal_chrony
  src: git+https://gitlab.internal/infra/chrony-role.git
  version: main             # branch, tag, or commit SHA

# From a collection (alternative path — see ansible-collection page)
- src: your_namespace.your_collection

Install roles from requirements.yml

# Install to the roles_path defined in ansible.cfg
ansible-galaxy role install -r roles/requirements.yml

# Force re-download (upgrade pinned versions)
ansible-galaxy role install -r roles/requirements.yml --force

Set roles_path = roles in ansible.cfg so installed roles land in your project's roles/ directory alongside local roles. Without this, Galaxy installs to ~/.ansible/roles/ which breaks CI.

# ansible.cfg snippet
[defaults]
roles_path = roles
CI tip: Add roles/requirements.yml install as the first step of your pipeline before any ansible-playbook commands.

import_role vs include_role

Both let you call a role from inside a task list, but they behave very differently:

Featureimport_roleinclude_role
When resolvedParse time (static)Runtime (dynamic)
Works with --tagsYes — role tasks inherit playbook tagsNo — tags inside the role are invisible to --tags
Works with when: on the taskPartially — condition applied to every task in the roleYes — condition controls whether the role runs at all
Loop (loop:) supportNoYes
Best forStandard inclusion where tags matterConditional/looped inclusion
# import_role — static, tags work
- name: Apply NTP role
  ansible.builtin.import_role:
    name: chrony_client
  tags: ntp

# include_role — dynamic, loop works
- name: Apply role to each environment
  ansible.builtin.include_role:
    name: "{{ item }}_setup"
  loop:
    - db
    - web
    - cache
Gotcha: If you use include_role and run with --tags ntp, the role's tasks will not be tagged and will be skipped. Use import_role when you need --tags to work correctly.