CoursesAnsibleRoles & reuse

Roles & reuse

Package automation into shareable units.

Intermediate12 min · lesson 7 of 12

A meal kit turns up as one box. The sauce sits in the same compartment every time, the recipe card is on top, and you never hunt for the garlic. That sameness is the product. An Ansible role does the same job for automation: a directory with a fixed set of named subfolders, so a role written by a stranger keeps its tasks, its variables, its templates and its restart instructions in exactly the places yours does. You can run it without reading a manual first.

Without roles, you copy. The block of tasks that installs nginx and locks it down gets pasted into the web playbook, then into staging, then into a teammate's repository. Six months on there are five copies. The day a CVE (Common Vulnerabilities and Exposures, the public tracking number given to a known security bug) lands against that config, you patch four of them and forget the fifth. The fifth is the one that stays exposed. A role folds those copies into one named unit with one place to fix.

There is a sharper reason to care, and most introductions skip it. A role is code. Roles almost always run under become: true (Ansible's way of saying run this with sudo), which means root on every host in the play. Choosing to reuse a role and choosing to trust it are the same decision, and you make it the moment you type its name.

The Fixed Folder Layout

A hardware store keeps every size of screw in a labelled drawer, not in whatever box had room that morning. Roles work the same way: convention instead of configuration. Ansible checks a known set of subdirectories, and inside the ones that hold YAML (the indented text format playbooks are written in) it loads a file called main.yml on its own. You never write an include path. Build the skeleton with the tool rather than by hand, so you get every drawer, including the ones you leave empty today.

terminal
# scaffold a role named webserver inside a project-local roles/ directory
ansible-galaxy role init --init-path=roles webserver
output
- Role webserver was created successfully
terminal
# LC_ALL=C so the sort order is identical on every machine
find roles/webserver | LC_ALL=C sort
output
roles/webserver
roles/webserver/README.md
roles/webserver/defaults
roles/webserver/defaults/main.yml
roles/webserver/files
roles/webserver/handlers
roles/webserver/handlers/main.yml
roles/webserver/meta
roles/webserver/meta/main.yml
roles/webserver/tasks
roles/webserver/tasks/main.yml
roles/webserver/templates
roles/webserver/tests
roles/webserver/tests/inventory
roles/webserver/tests/test.yml
roles/webserver/vars
roles/webserver/vars/main.yml

tasks/main.yml is the front door: it runs when something calls the role. defaults/main.yml holds values a caller is expected to override. vars/main.yml holds values the role treats as its own business. handlers/main.yml holds the restart-and-reload tasks that other tasks notify, which works like a bell you ring for the kitchen instead of walking in and cooking yourself. meta/main.yml carries the role's metadata and its dependencies on other roles. files/ and templates/ get special treatment: ansible.builtin.copy and ansible.builtin.template look in the calling role's own files/ and templates/ first, so inside a role you write a bare filename and no path at all. The tests/ pair is a starter smoke-test playbook, meaning a tiny run that proves the basics work, and you can keep it or delete it.

roles/webserver/tasks/main.yml
---
- name: Install nginx
ansible.builtin.package:
name: "{{ webserver_package }}"
state: present
- name: Render the nginx config
ansible.builtin.template:
src: nginx.conf.j2 # found in this role's templates/, no path needed
dest: "{{ webserver_config_path }}"
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s # check the rendered file before installing it
notify: Reload nginx
roles/webserver/handlers/main.yml
---
- name: Reload nginx
ansible.builtin.service:
name: "{{ webserver_service }}"
state: reloaded

Two lines in that task file are doing quiet work. The src carries no directory because the template module searches this role's templates/ folder first, so moving the role into another project does not break the reference. And validate: nginx -t -c %s tells Ansible to write the rendered file to a temporary path on the managed host, run nginx's own syntax checker against it, and refuse to install it if the check fails. Drop that line and one bad variable ships a broken config to every web server, where the next reload takes the site down. The notify fires only when the template task actually reports changed, so a run that alters nothing reloads nothing. That is idempotence, meaning a second run changes nothing, doing its job.

Two Variable Folders That Behave Nothing Alike

A washing machine has dials on the front and screws inside the casing. The dials are for you. The screws are for the person who built it. A role splits its variables the same way, and the split is the whole point of having two folders.

roles/webserver/defaults/main.yml
---
# Anything a caller might want to change lives here and nowhere else.
webserver_port: 443
webserver_server_name: localhost
# webserver_allowed_cidrs is deliberately absent. It is a required option
# in meta/argument_specs.yml, and a default here would satisfy that
# requirement and switch the check off for good.
roles/webserver/vars/main.yml
---
# Constants the role owns. Callers have no business changing these.
webserver_package: nginx
webserver_service: nginx
webserver_config_path: /etc/nginx/nginx.conf

Those two files look alike and behave nothing alike. Ansible keeps a long variable precedence list, and they sit at opposite ends of it. defaults/main.yml is the floor: your inventory (the list of hosts Ansible manages), group_vars (a file of variables applied to every host in a named group), host_vars, play vars and -e on the command line all beat it, which is exactly what you want for anything a caller might tune. vars/main.yml sits far higher, above group_vars and host_vars and above play vars. So a user who sets webserver_package in their inventory and sees nothing change is hitting that rule, with no error message to search for. Extra vars passed with -e sit at the very top and beat everything, including vars/, which makes -e a reliable emergency override and a terrible everyday habit.

defaults/ and vars/ are not interchangeable
Putting a tunable value in vars/main.yml is the classic role bug, and it fails silently. The caller sets webserver_port in their group_vars, runs the play, gets 443 anyway, and has no error text to search for. Rule of thumb: if a caller might ever want to change it from their inventory, it belongs in defaults/. Reserve vars/ for constants the role owns, like per-OS package names or a fixed config path. Never define the same variable in both files, because vars/ wins and nothing warns you. When a value truly cannot move, -e on the command line beats vars/ and everything else.

Calling A Role From A Play

roles/webserver/meta/main.yml
---
galaxy_info:
author: platform-team
description: Serve one site over TLS, restricted to named networks
license: MIT
min_ansible_version: "2.16" # quote it: unquoted 2.10 parses as the number 2.1
galaxy_tags:
- web
- nginx
# Pulled in and run before this role's own tasks.
dependencies:
- role: tls_cert
vars:
tls_cert_domain: "{{ webserver_server_name }}"
site.yml
---
- name: Configure the public web tier
hosts: web
become: true
pre_tasks:
- name: Refresh the package index
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
roles:
- role: baseline # no per-call values
- role: webserver
vars: # call-site values, above the role's defaults/
webserver_port: 8443
webserver_server_name: shop.example.com
webserver_allowed_cidrs:
- 10.20.0.0/16
- 10.30.4.0/24
tasks:
# dynamic: which roles run is not known until the play is running
- name: Apply one app role per enabled app
ansible.builtin.include_role:
name: "app_{{ item }}"
loop: "{{ enabled_apps | default([]) }}"

That description line mentions TLS (Transport Layer Security, what puts the lock icon on an https:// address), and the tls_cert dependency is the role that gets the certificate in place. The roles: keyword is the everyday way in. Every role listed there runs before the play's own tasks: block, which catches people who expect strict top-to-bottom reading order. pre_tasks runs earlier still, and post_tasks runs last. Each role entry can carry its own vars:, so one role can be called twice with a different port and a different server name, and those call-site values beat anything in the role's defaults/. Dependencies declared in meta/main.yml are pulled in ahead of the role that needs them, which is why tls_cert runs before webserver without the play mentioning it at all.

The keywords import_role and include_role give you finer control. import_role is static: Ansible reads it while parsing the playbook, so its tasks are known before the run starts, and a tag (a label you attach to tasks so you can run or skip a subset) placed on the import applies to every task inside. include_role is dynamic, resolved during the run, and it is the only way to loop over role names or pick one with a when. The trade is visibility. A dynamic include is opaque until it executes, and a tag on it covers the include itself and nothing within, unless you add apply:. One rule catches everybody eventually: a role that already ran in a play will not run again, even if you list it twice, unless the parameters differ or its meta/main.yml sets allow_duplicates: true. The second listing produces no output and no skipped line in the recap, so it reads like Ansible ignored your file.

Order of play when roles are involved
1pre_tasks
anything that must precede every role
2handlers flush
handlers notified by pre_tasks fire here
3role dependencies
from meta/main.yml, before the role that needs them
4roles:
arg spec check first, then tasks/main.yml
5tasks:
the play's own list, after every role
6handlers flush
roles and tasks share this one flush point
7post_tasks
smoke tests, then a final flush

Check The Wiring Before You Run It

terminal
# print the compiled task list without touching a single host
ansible-playbook -i inventory site.yml --list-tasks
output
playbook: site.yml
play #1 (web): Configure the public web tier TAGS: []
tasks:
Refresh the package index TAGS: []
baseline : Install the base package set TAGS: []
baseline : Harden sshd TAGS: []
tls_cert : Ensure the certificate exists TAGS: []
webserver : Validating arguments against arg spec 'main' - Serve one site over TLS, restricted to named networks TAGS: [always]
webserver : Install nginx TAGS: []
webserver : Render the nginx config TAGS: []
Apply one app role per enabled app TAGS: []

Read that as the compiled plan. Role tasks appear with the role name in front, which is how you confirm Ansible found the role you meant and not a same-named copy elsewhere on the search path. It looks in a roles/ directory next to the playbook first, then in roles_path, which defaults to ~/.ansible/roles, /usr/share/ansible/roles and /etc/ansible/roles. The odd line about an arg spec is a task Ansible inserted for you, and the next section builds it. Now notice what is missing. The include_role task shows up once, under its own name, with nothing beneath it, because dynamic includes are not expanded at parse time. Whatever hides behind one stays invisible until the run. This listing is a parse-time plan, not a prediction of what executes, so a role you listed twice still shows two sets of tasks here even though the second set gets passed over at run time. To see effects rather than plans, follow up with --check --diff against one host using --limit, and run ansible-lint roles/webserver to check the role on its own with no playbook involved.

Roles do not only live in a roles/ folder. Collections ship them too. A collection is a bundle of modules, roles and plugins packaged and versioned as one unit, and you address a role inside one by its fully qualified collection name (FQCN: namespace, then collection, then role), as in fedora.linux_system_roles.timesync. That is a full postal address rather than a street name, so it skips the search path entirely and there is no argument about which copy ran. Mixing both styles is normal and healthy: the roles you wrote sit in roles/ next to the playbook and travel with the repository, while anything you did not write arrives through a pinned collection or a pinned Galaxy install.

Make The Role Refuse Bad Input

A role's defaults tell a caller what they may set. Nothing so far tells them what they must set, and that gap is where security bugs live. A form at a doctor's office will not be accepted with the allergies box blank, and that refusal is the whole feature. meta/argument_specs.yml gives your role the same power: a typed contract for its inputs that Ansible checks by itself, before the role's first real task. Mark webserver_allowed_cidrs (CIDR is Classless Inter-Domain Routing, the 10.20.0.0/16 way of writing a block of addresses) as required with no default anywhere, and a caller who forgets the allowlist never gets past the front door. Each entry point gets its own key, so a role that offers an alternative way in through tasks_from: install.yml validates that path separately under an install: key.

roles/webserver/meta/argument_specs.yml
---
argument_specs:
main: # the spec for tasks/main.yml
short_description: Serve one site over TLS, restricted to named networks
options:
webserver_server_name:
type: str
required: true
description: Host name the site answers to
webserver_port:
type: int
description: TCP port the site listens on
webserver_allowed_cidrs:
type: list
elements: str
required: true
description: Networks allowed to reach the port
terminal
# someone copied the play for staging and dropped the allowlist lines
ansible-playbook -i inventory site.yml --limit web01
output
PLAY [Configure the public web tier] *******************************************
TASK [Gathering Facts] *********************************************************
ok: [web01]
TASK [Refresh the package index] ***********************************************
ok: [web01]
TASK [baseline : Install the base package set] *********************************
ok: [web01]
TASK [baseline : Harden sshd] **************************************************
ok: [web01]
TASK [tls_cert : Ensure the certificate exists] ********************************
ok: [web01]
TASK [webserver : Validating arguments against arg spec 'main' - Serve one site over TLS, restricted to named networks] ***
fatal: [web01]: FAILED! => {"argument_errors": ["missing required arguments: webserver_allowed_cidrs"], "argument_spec_data": {"webserver_allowed_cidrs": {"description": "Networks allowed to reach the port", "elements": "str", "required": true, "type": "list"}, "webserver_port": {"description": "TCP port the site listens on", "type": "int"}, "webserver_server_name": {"description": "Host name the site answers to", "required": true, "type": "str"}}, "changed": false, "msg": "Validation of arguments failed:\nmissing required arguments: webserver_allowed_cidrs", "validate_args_context": {"argument_spec_name": "main", "name": "webserver", "path": "/home/ops/infra/roles/webserver", "type": "role"}}
PLAY RECAP *********************************************************************
web01 : ok=5 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0

That failure costs about a second, and it lands before a single package is installed or a config file is written. Compare it against the alternative. A role author who writes the allowlist as a Jinja expression (Jinja is the template language behind Ansible's {{ }} placeholders) with a fallback of 0.0.0.0/0 ships a listener open to the whole internet, quietly, on every host, and the run still reports green. The check has one requirement that is easy to get wrong: a required option must have no value in defaults/main.yml, because a default makes the variable present and the requirement can then never fire. The validation task carries the always tag, so narrowing a run with --tags still performs it. Only an explicit --skip-tags always gets around it, which is exactly as visible as it should be. The same file doubles as documentation you can print with ansible-doc -t role -r roles webserver.

Installing Someone Else's Role

Ansible Galaxy is the public index where people publish roles and collections, and installing from it takes one command. What you want alongside that is a receipt: a file inside your repository saying exactly which version of which role this project runs, so the next person and the build server get the same bytes you did. requirements.yml is that receipt.

requirements.yml
---
roles:
# Galaxy role pinned to a tag. Tags can be moved, so this is the weaker pin.
- name: geerlingguy.nginx
version: "3.1.4"
# Git role pinned to a commit SHA. Exact bytes, and it cannot be re-pointed.
- name: app
src: https://github.com/acme/ansible-role-app.git
scm: git
version: "0f5a1c8e3b2d4f6a8c9e1b3d5f7a9c1e3b5d7f9a"
collections:
- name: community.general
version: "8.6.0"
# one file, both lists: ansible-galaxy install -r requirements.yml
terminal
ansible-galaxy install -r requirements.yml
output
Starting galaxy role install process
- downloading role 'nginx', owned by geerlingguy
- downloading role from https://github.com/geerlingguy/ansible-role-nginx/archive/3.1.4.tar.gz
- extracting geerlingguy.nginx to /home/ops/.ansible/roles/geerlingguy.nginx
- geerlingguy.nginx (3.1.4) was installed successfully
- extracting app to /home/ops/.ansible/roles/app
- app (0f5a1c8e3b2d4f6a8c9e1b3d5f7a9c1e3b5d7f9a) was installed successfully
Starting galaxy collection install process
Process install dependency map
Starting collection install process
Downloading https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-general-8.6.0.tar.gz to /home/ops/.ansible/tmp/ansible-local-8213hs4kq1zr/tmp0v9wbnq2/community-general-8.6.0-i4t2r8xa
Installing 'community.general:8.6.0' to '/home/ops/.ansible/collections/ansible_collections/community/general'
community.general:8.6.0 was installed successfully

Pinning is the difference between a build that reproduces and a build that drifts. Both role entries above are pinned, but not equally well. A Galaxy version like 3.1.4 points at a Git tag, and a tag is a sticker: someone can peel it off and stick it on a different jar without changing what the label says. A 40-character commit SHA (Secure Hash Algorithm, the hexadecimal fingerprint Git gives every commit) names exact bytes and cannot be re-pointed. Prefer a SHA for anything you did not write. Roles land in the first writable directory of roles_path and collections under ~/.ansible/collections, so third-party code never ends up committed inside your own repository, and a single requirements.yml carries both kinds.

terminal
ansible-galaxy role list
output
# /home/ops/.ansible/roles
- app, 0f5a1c8e3b2d4f6a8c9e1b3d5f7a9c1e3b5d7f9a
- geerlingguy.nginx, 3.1.4
[WARNING]: - the configured path /usr/share/ansible/roles does not exist.
[WARNING]: - the configured path /etc/ansible/roles does not exist.

That listing answers the question of what is on this machine, which is not always the same as what requirements.yml says. A control node (the machine you run ansible-playbook from) that has been in service a while collects roles installed by hand, by an older requirements file, or by a colleague. ansible-galaxy install leaves an already-present role alone and prints a line like "- geerlingguy.nginx (3.1.4) is already installed, skipping." unless you pass --force, so a stale copy can outlive the pin that was supposed to replace it. When the two lists disagree, the run you are about to make is not the run your repository describes. The cheap fix is a fresh control node, or a throwaway roles directory per build, so nothing survives from one run to the next.

terminal
# before you trust a role you did not write, find out what it actually does
grep -rnE '^[[:space:]]*-?[[:space:]]*(ansible\.builtin\.)?(shell|command|raw|script|get_url|unarchive):' ~/.ansible/roles/app/
output
/home/ops/.ansible/roles/app/tasks/install.yml:14: ansible.builtin.get_url:
/home/ops/.ansible/roles/app/tasks/install.yml:22: ansible.builtin.unarchive:
/home/ops/.ansible/roles/app/tasks/install.yml:31: ansible.builtin.shell: |
/home/ops/.ansible/roles/app/tasks/main.yml:9: command: /opt/app/bin/app --migrate

Read the hits rather than counting them. The pattern allows both spellings on purpose, because plenty of published roles still use the old short names, and searching for ansible.builtin.shell alone would have missed that last line entirely. get_url and unarchive mean the role fetches something off the internet while it runs, so the pin in requirements.yml covers the role and not the payload it downloads. A shell or command task means arbitrary commands as root. None of that is automatically wrong, since a role that installs software has to do some of it. What matters is that you looked, that the URL belongs to a host you would trust with root on your fleet, and that you can say out loud what this role does without shrugging.

A role you downloaded runs as root
Ansible has no sandbox. A role from Galaxy or a Git URL executes on every host in the play with whatever privilege become gave it, and from there it can read your files, reach your internal network and install whatever it likes. Treat it like a package from npm or PyPI (the public code registries for JavaScript and Python): pin it to an exact version or commit, read what it does before adopting it, keep a reviewed copy in your own Git host if the source is not one you would trust with production, and re-review when you bump the pin. Keep ansible-galaxy install --force out of habitual use too, because it replaces an installed role with whatever the source serves today.
Quick check
01You are writing a role that configures nginx, and callers should be able to change the listening port from their own group_vars file. Where does the role author put the default value of 443?
Incorrect — vars/main.yml outranks group_vars, so the caller's setting would be ignored with no error shown.
Incorrect — meta/main.yml holds role metadata and dependencies, not variable values.
Correct — role defaults are the lowest-precedence variables, so inventory, group_vars, host_vars, play vars and -e all override them.
Incorrect — set_fact sits high in precedence, so it would clobber the caller's group_vars value and hide the default from anyone reading the role.
02A play has roles: listing baseline, then webserver, then baseline again, with identical parameters both times and no allow_duplicates setting. What happens on the run?
Incorrect — Ansible passes over a role that has already run in the same play unless its parameters differ or allow_duplicates is set.
Correct — a role that already ran in the play is silently passed over at run time, so the duplicate produces neither task output nor a recap entry.
Incorrect — listing a role twice is valid syntax and the playbook parses fine, which is why --list-tasks still shows both copies.
Incorrect — the duplicate never becomes a task result, so the skipped counter stays where it was and the recap gives you no hint.
03Your role's meta/argument_specs.yml marks webserver_allowed_cidrs as required: true, but every run passes validation even on hosts where nobody sets that variable anywhere. What is going on?
Incorrect — Ansible inserts the validation task automatically whenever a role has an argument spec, and you can see it in --list-tasks.
Incorrect — the required flag is enforced the same way for every type, including list.
Incorrect — validation reads the task variables in scope, which include defaults, group_vars, host_vars and play vars, not only call-site parameters.
Correct — a default makes the value exist on every host, which satisfies required and silently disables the guard.

Try this

Run ansible-galaxy role init --init-path=roles webserver on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: defaults/ and vars/ are not interchangeable. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related