Ansible Roles in Practice
- What a role is and why it exists
- Role directory layout
- defaults/main.yml
- vars/main.yml
- tasks/main.yml and sub-task files
- handlers/main.yml
- templates/
- files/
- meta/main.yml
- Calling a role from a playbook
- Real example: chrony role
- Generating a role skeleton
- Installing third-party roles
- import_role vs include_role
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:
- Reusability — the same role can be applied to any host or group
- Organisation — tasks, templates, variables, and handlers are separate files in predictable locations
- Shareability — roles can be published to Ansible Galaxy or shared between projects
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
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
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/, 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:
- Ansible reads the inventory and connects to all target hosts
- It finds the
chrony_clientrole in theroles/directory - It loads
defaults/main.yml— the NTP server defaults are available - It checks group_vars — if
chrony_serversis defined there, it overrides the defaults - It runs
tasks/main.yml, which calls install → config → service → verify - The config task renders
chrony.conf.j2with the resolved variables - If the rendered file differs from what is on disk, the template task reports "changed" and notifies "Restart chronyd"
- After all tasks complete, Ansible runs the handler — chronyd restarts
- The verify task runs
chronyc trackingto 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
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:
| Feature | import_role | include_role |
|---|---|---|
| When resolved | Parse time (static) | Runtime (dynamic) |
Works with --tags | Yes — role tasks inherit playbook tags | No — tags inside the role are invisible to --tags |
Works with when: on the task | Partially — condition applied to every task in the role | Yes — condition controls whether the role runs at all |
Loop (loop:) support | No | Yes |
| Best for | Standard inclusion where tags matter | Conditional/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
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.