9. Roles and collections
Ansible · 9. Roles and collectionsA 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.
A role is a directory layout
Section titled “A role is a directory layout” 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.mdNothing 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, weakestnginx_port: 80nginx_worker_processes: autonginx_client_max_body_size: 1mnginx_ssl_enabled: false# roles/nginx/vars/main.yml — precedence 15, near-strongestnginx_package_name: "{{ 'nginx' if ansible_facts['os_family'] == 'Debian' else 'nginx' }}"nginx_config_path: /etc/nginx/nginx.confnginx_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.
argument_specs — validated inputs
Section titled “argument_specs — validated inputs”Since Ansible 2.11, a role can declare and validate its interface:
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”dependencies: - role: common - role: nginx vars: nginx_port: 8080Roles 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 - appPrefer 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:
- ansible.builtin.import_role: name: commonThe three ways to use a role
Section titled “The three ways to use a role”# 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 | boolThe 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.ymlThat 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.
Collections
Section titled “Collections”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_vaultansible-galaxy collection install community.generalansible-galaxy collection install -r requirements.ymlansible-galaxy collection list# requirements.yml — commit thiscollections: - 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.0Pin 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 listWrite 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.
Where Ansible looks for roles
Section titled “Where Ansible looks for roles” 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_rolescollections_path = ./collectionsPut 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.
Galaxy, and what to take from it
Section titled “Galaxy, and what to take from it”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.
Structuring a real repository
Section titled “Structuring a real repository” 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.ymlOne 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.
ansible-playbook -i inventory/production playbooks/site.ymlThe 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.
What to take from this chapter
Section titled “What to take from this chapter”- A role is a directory layout with fixed names. Nothing needs configuring;
template:andcopy: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 invars/cannot be overridden fromgroup_varsand produces the most frustrating failure in Ansible.meta/argument_specs.ymlvalidates 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_rolewithtasks_from:lets one role expose several entry points.- Since 2.10, modules live in collections. Pin versions in
requirements.ymland 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.ymlof 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.