Skip to content

9. Roles and collections

A playbook with three hundred tasks in it is a script with extra steps. Roles are how the same content becomes something you can reuse, test and hand to someone else.

roles/nginx/
tasks/main.yml ← the entry point
handlers/main.yml ← handlers this role defines
templates/ ← .j2 files; `template:` looks here
files/ ← static files; `copy:` looks here
vars/main.yml ← internal constants (precedence 15)
defaults/main.yml ← the public interface (precedence 2)
meta/main.yml ← dependencies and metadata
meta/argument_specs.yml ← validated arguments
library/ ← custom modules
filter_plugins/ ← custom Jinja filters
README.md

Nothing here needs configuring. Ansible finds tasks/main.yml because it is called that. template: src=nginx.conf.j2 finds roles/nginx/templates/nginx.conf.j2 with no path, because the search is relative to the role. That convention is the whole mechanism.

- hosts: webservers
roles:
- nginx
- role: app
vars:
app_version: "2.4.0"

defaults versus vars — the decision that defines the role

Section titled “defaults versus vars — the decision that defines the role”

Chapter 6 gave the precedence numbers; here is the design consequence.

# roles/nginx/defaults/main.yml — precedence 2, weakest
nginx_port: 80
nginx_worker_processes: auto
nginx_client_max_body_size: 1m
nginx_ssl_enabled: false
# roles/nginx/vars/main.yml — precedence 15, near-strongest
nginx_package_name: "{{ 'nginx' if ansible_facts['os_family'] == 'Debian'
else 'nginx' }}"
nginx_config_path: /etc/nginx/nginx.conf
nginx_service_name: nginx

defaults/ is the role’s API. vars/ is its private state.

Every value a caller might reasonably want to change goes in defaults/. Anything in vars/ cannot be overridden by group_vars, host_vars, or the inventory — only by -e or a role parameter — so putting a user-facing setting there produces the single most frustrating failure in Ansible: a variable that visibly does not take effect no matter where you set it.

Since Ansible 2.11, a role can declare and validate its interface:

roles/nginx/meta/argument_specs.yml
argument_specs:
main:
short_description: Install and configure nginx
options:
nginx_port:
type: int
default: 80
description: Port to listen on
nginx_ssl_enabled:
type: bool
default: false
nginx_upstreams:
type: list
elements: dict
required: true
options:
name: {type: str, required: true}
servers: {type: list, elements: str, required: true}

This is genuinely underused and worth the ten minutes. It fails immediately with a precise message when a caller passes a string where a list belongs, it documents the role in a machine-readable way, and it catches the chapter 7 string-versus-boolean problem at the boundary rather than three tasks later.

Dependencies, and why you probably do not want them

Section titled “Dependencies, and why you probably do not want them”
roles/app/meta/main.yml
dependencies:
- role: common
- role: nginx
vars:
nginx_port: 8080

Roles listed here run before the role that declares them, automatically.

They also come with behaviour that surprises people:

A dependency runs once per unique parameter set. Listing common as a dependency of five roles runs it once, not five times — unless the parameters differ, in which case it runs again. This deduplication is helpful and invisible, and it makes the actual execution order hard to predict.

Dependencies are invisible in the playbook. Someone reading roles: [app] has no way to know that common and nginx also ran without opening meta/main.yml.

The alternative is explicit:

- hosts: webservers
roles:
- common
- nginx
- app

Prefer this. The order is visible, there is no deduplication to reason about, and the playbook says what happens. Reserve meta/dependencies for genuine hard requirements — a role that literally cannot function without another — and even then, consider import_role inside the tasks instead:

roles/app/tasks/main.yml
- ansible.builtin.import_role:
name: common
# 1. In the roles: section — runs before tasks: (chapter 4)
roles:
- nginx
# 2. import_role — static, inserted at parse time
- ansible.builtin.import_role:
name: nginx
# 3. include_role — dynamic, at runtime
- ansible.builtin.include_role:
name: "{{ webserver_role }}" # a variable! Only include can
when: install_webserver | bool

The import versus include distinction from chapter 8 applies unchanged: import for predictability and working tags, include when the role name or the decision is a runtime thing.

One extra behaviour worth knowing: include_role can run just part of a role.

- ansible.builtin.include_role:
name: nginx
tasks_from: certificates.yml # not main.yml

That lets a role expose several entry points — install.yml, configure.yml, certificates.yml — which is a clean way to structure a role that does several separable things.

In Ansible 2.10 the project split its thousands of modules out of the core repository into collections: versioned, independently released bundles of modules, roles, plugins and filters.

A COLLECTION: namespace.name
ansible.builtin ships inside ansible-core
ansible.posix mount, sysctl, authorized_key, seboolean
community.general the big grab-bag — hundreds of modules
community.crypto certificates, keys, CSRs
community.docker
community.postgresql / community.mysql
kubernetes.core
amazon.aws / google.cloud / azure.azcollection
ansible.windows / community.windows
cisco.ios / arista.eos / juniper.junos
community.hashi_vault
Terminal window
ansible-galaxy collection install community.general
ansible-galaxy collection install -r requirements.yml
ansible-galaxy collection list
# requirements.yml — commit this
collections:
- name: community.general
version: ">=8.0.0,<9.0.0"
- name: ansible.posix
version: "1.5.4"
- name: community.postgresql
roles:
- name: geerlingguy.postgresql
version: "3.5.0"
- src: https://github.com/example/ansible-role-internal.git
scm: git
version: v2.1.0

Pin versions in requirements.yml and commit it. A collection that updates between your test run and your production run is a supply-chain change you did not review, and collections do make breaking changes.

- ansible.builtin.copy: # explicit — do this
- copy: # short name — still works
- community.general.pam_limits: # required for non-builtin
# or declare them once per play:
- hosts: all
collections:
- community.general
tasks:
- pam_limits: # resolves via the list

Write the FQCN. ansible-lint requires it, it is unambiguous, and it tells the reader where the module comes from. The collections: keyword works and creates a lookup order that has to be reasoned about — the explicit name never does.

1. roles/ next to the playbook
2. roles_path in ansible.cfg
(default: ~/.ansible/roles:/usr/share/ansible/roles:
/etc/ansible/roles)
3. inside installed collections, as
namespace.collection.rolename
# ansible.cfg
[defaults]
roles_path = ./roles:./galaxy_roles
collections_path = ./collections

Put third-party roles somewhere separate from your own and gitignore it. Committing galaxy_roles/ into your repository means vendoring someone else’s code with no version tracking; requirements.yml plus a gitignored install directory is the version-tracked equivalent.

Ansible Galaxy hosts community roles and collections. Some are excellent — geerlingguy’s roles in particular are widely used and genuinely well made. Many are abandoned.

Before adopting one, three checks:

✓ when was the last commit?
✓ does it support the OS versions you actually run?
✓ read tasks/main.yml. All of it.

That third one is not optional. A role runs as root on every machine you own. It is a much larger trust decision than a library in an application, and it is usually only a few hundred lines — reading it is twenty minutes well spent.

And the honest position: for anything simple, write it yourself. A role that installs a package, writes a config file and manages a service is thirty lines you fully understand. A community role for the same thing is four hundred lines supporting eleven distributions and forty options you will never use. Take community roles for genuinely complicated things — a database cluster, a certificate authority — and write the simple ones.

ansible/
ansible.cfg
requirements.yml
inventory/
production/
hosts.yml
group_vars/
host_vars/
staging/
hosts.yml
group_vars/
roles/ ← yours
galaxy_roles/ ← installed, gitignored
collections/ ← installed, gitignored
playbooks/
site.yml
webservers.yml
databases.yml
maintenance/
rotate-certs.yml
patch.yml

One inventory directory per environment is the shape that scales, because it makes --limit production unnecessary — you point at a different inventory entirely, and there is no way to accidentally run staging variables against production hosts.

Terminal window
ansible-playbook -i inventory/production playbooks/site.yml

The alternative — one inventory with a production group — works and depends on everyone always remembering --limit. Separate directories make the mistake impossible rather than merely discouraged.

  • A role is a directory layout with fixed names. Nothing needs configuring; template: and copy: find files relative to the role automatically.
  • defaults/ is the role’s public API (weakest precedence); vars/ is private (near-strongest). A user-facing setting in vars/ cannot be overridden from group_vars and produces the most frustrating failure in Ansible.
  • meta/argument_specs.yml validates and documents the interface, and catches type errors at the boundary. Underused.
  • Prefer listing roles explicitly over meta/dependencies. Dependencies are invisible in the playbook and deduplicate in ways that make ordering hard to predict.
  • include_role with tasks_from: lets one role expose several entry points.
  • Since 2.10, modules live in collections. Pin versions in requirements.yml and commit it — a collection that changes between test and production is an unreviewed change.
  • Write FQCNs.
  • Install third-party roles to a gitignored directory, tracked by requirements.yml, not committed.
  • Read tasks/main.yml of any community role before using it — it runs as root everywhere. Write the simple roles yourself; take community ones for genuinely hard things.
  • One inventory directory per environment makes the wrong-environment mistake impossible rather than merely discouraged.

Next: a play traced end to end — every step from parsing the inventory to the final recap line, with the timing of each.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source