Roles and Collections
Structuring Ansible work: the role layout, defaults and handlers in roles, collections and fully qualified names, Ansible Galaxy, and pinning what you install.
From one playbook to reusable parts
Kestrel's first site.yml (see
Playbooks and Idempotency) configures nginx on the portal servers and PostgreSQL access on
db01 in one file. That is fine for two plays. It stops being fine
when a second playbook also needs the nginx part, when the test
environment needs it with other values, or when a team in another
department wants to use it. Copying tasks between playbooks creates
the same drift between copies that infrastructure as code was meant
to remove.
Ansible's answer has two levels. A role bundles everything for one job so that a play can say "apply the nginx_portal role" instead of listing twenty tasks. A collection bundles roles, modules and plugins into a versioned package that can be published and installed. The same ideas exist for Terraform and OpenTofu (see Modules and Reuse) and for Python code (modules and packages).
Roles
An Ansible role is a directory
with a fixed layout that holds the tasks, handlers, variables,
templates and files for one configuration job. Because the layout is
fixed, Ansible knows where to find each part: no
include lines, no paths to templates. The
ansible-galaxy role init command creates the skeleton:
$ cd /home/amara/kestrel-infra/ansible
$ ansible-galaxy role init --init-path roles nginx_portal
$ find roles/nginx_portal | sort
- Role nginx_portal was created successfully roles/nginx_portal roles/nginx_portal/defaults roles/nginx_portal/defaults/main.yml roles/nginx_portal/files roles/nginx_portal/handlers roles/nginx_portal/handlers/main.yml roles/nginx_portal/meta roles/nginx_portal/meta/main.yml roles/nginx_portal/README.md roles/nginx_portal/tasks roles/nginx_portal/tasks/main.yml roles/nginx_portal/templates roles/nginx_portal/tests roles/nginx_portal/tests/inventory roles/nginx_portal/tests/test.yml roles/nginx_portal/vars roles/nginx_portal/vars/main.yml
| Directory | Holds |
|---|---|
tasks/main.yml |
The tasks the role runs, in order |
handlers/main.yml |
Handlers the role's tasks can notify |
defaults/main.yml |
Default values for the role's variables (lowest precedence) |
vars/main.yml |
Internal constants of the role (high precedence, hard to override) |
templates/ |
Jinja2 templates for the template module |
files/ |
Static files for the copy module |
meta/main.yml |
Author, license, supported platforms and dependencies on other roles |
tests/, README.md |
A test inventory and playbook, and the documentation |
Delete the directories you do not use. The generated files are
mostly comments; in ansible-core 2.19 each starts with
#SPDX-License-Identifier: MIT-0, and
meta/main.yml contains placeholders ("your name",
min_ansible_version: 2.1) that you should replace
before anyone else uses the role. Ansible finds roles in a
roles/ directory next to the playbook, and otherwise in
the configured roles path (by default ~/.ansible/roles,
/usr/share/ansible/roles and
/etc/ansible/roles).
Ravi Kumar moved the nginx part of site.yml into the
role. The tasks, handler and template are the ones from the
playbook, with variable names prefixed by the role name:
roles/nginx_portal/tasks/main.yml:
---
- name: Ensure the nginx conf.d directory exists
ansible.builtin.file:
path: "{{ nginx_portal_conf_dir }}"
state: directory
mode: "0755"
- name: Render the portal upstream
ansible.builtin.template:
src: portal-upstream.conf.j2
dest: "{{ nginx_portal_conf_dir }}/portal-upstream.conf"
mode: "0644"
notify: Reload nginx
roles/nginx_portal/handlers/main.yml:
---
- name: Reload nginx
ansible.builtin.debug:
msg: "nginx on {{ inventory_hostname }} would be reloaded now"
roles/nginx_portal/templates/portal-upstream.conf.j2:
# {{ ansible_managed }}
upstream kestrel_portal {
{% for host in groups['web'] %}
server {{ hostvars[host].ansible_host }}:{{ nginx_portal_port }};
{% endfor %}
}
Note the paths: src: portal-upstream.conf.j2 without a
directory, because the template module looks in the
role's own templates/ first.
Role defaults
Role defaults are the values in
a role's defaults/main.yml: the documented settings of
the role, with sensible values, at the lowest precedence of any
variable in Ansible. Anything the caller sets — in
group_vars, host_vars, the play or the
role entry — overrides them (see
variable precedence).
---
nginx_portal_port: 8080
nginx_portal_conf_dir: "{{ kestrel_root }}/etc/nginx/conf.d"
vars/main.yml is the opposite: its values sit high in
the order and beat group_vars and
host_vars. Put only true internal constants there (a
package name that never changes); anything a user of the role might
want to change belongs in defaults/.
Using a role in a play
A play lists roles under roles:; Ansible runs each
role's tasks before the play's own tasks:. Values for
the role can be passed with the entry. portal.yml:
- name: Configure the portal web servers with a role
hosts: web
gather_facts: false
roles:
- role: nginx_portal
vars:
nginx_portal_port: 8443
$ ansible-playbook portal.yml
$ cat hosts/web01/etc/nginx/conf.d/portal-upstream.conf
PLAY [Configure the portal web servers with a role] ****************************
TASK [nginx_portal : Ensure the nginx conf.d directory exists] *****************
ok: [web01]
ok: [web02]
TASK [nginx_portal : Render the portal upstream] *******************************
changed: [web02]
changed: [web01]
RUNNING HANDLER [nginx_portal : Reload nginx] **********************************
ok: [web01] => {
"msg": "nginx on web01 would be reloaded now"
}
ok: [web02] => {
"msg": "nginx on web02 would be reloaded now"
}
PLAY RECAP *********************************************************************
web01 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
# Ansible managed
upstream kestrel_portal {
server 192.0.2.11:8443;
server 192.0.2.12:8443;
}
Task and handler names are prefixed with the role (nginx_portal : …), which tells you where to look when something fails. The value
passed with the role entry (8443) replaced the role default (8080);
the template task reported changed because the file
still had port 8080 from the earlier site.yml run.
Besides roles:, a role can be used as a task, which
lets you place it between other tasks:
- name: Configure nginx for the portal (static)
ansible.builtin.import_role:
name: nginx_portal
- name: Configure the billing export only in production (dynamic)
ansible.builtin.include_role:
name: billing_export
when: kestrel_env == "prod"
-
import_roleis static: Ansible reads the role's tasks when it loads the playbook, so they show up in--list-tasks, and awhenortagson the import is copied to every task inside. -
include_roleis dynamic: the role is loaded only when the task is reached, so it can depend on values known at run time and can be used in a loop; awhenapplies to the include itself, and tags on it do not reach the tasks inside unless you pass them on withapply.
When in doubt, use roles: or import_role;
reach for include_role when you need the dynamic
behavior.
Validating role arguments
A role can declare its inputs in
meta/argument_specs.yml. Ansible then checks the values
before the role's first task runs and stops with a clear message
when a required value is missing or has the wrong type, instead of
failing halfway with a confusing template error. The same file
documents the role for ansible-doc.
argument_specs:
main:
short_description: Configure the nginx upstream for the Kestrel portal
options:
nginx_portal_port:
type: int
default: 8080
description: Port the portal application listens on.
nginx_portal_conf_dir:
type: path
required: true
description: Directory for nginx configuration fragments.
Collections
An Ansible collection is
the packaging and distribution format for Ansible content: a
versioned bundle of modules, plugins (filters, inventory and
connection plugins, and more), roles and playbooks, named
namespace.collection. community.general
holds hundreds of community-maintained modules,
ansible.posix the POSIX-specific ones (such as
authorized_key), community.postgresql the
PostgreSQL modules; vendors publish collections for their network
devices and cloud platforms. Kestrel could publish its own roles as
a collection kestrel.platform:
ansible_collections/kestrel/platform/ ├── galaxy.yml name, version, authors, dependencies ├── meta/runtime.yml minimum ansible-core version, redirects ├── plugins/ │ ├── modules/ │ └── filter/ ├── roles/ │ ├── nginx_portal/ │ └── billing_export/ └── playbooks/
Installed collections live in a directory tree
ansible_collections/<namespace>/<collection>
under the collections path: by default
~/.ansible/collections and
/usr/share/ansible/collections, or a directory inside
the project. ansible-galaxy collection list shows what
is installed where:
$ ansible-galaxy collection list
# /home/amara/.venv/ansible/lib/python3.11/site-packages/ansible/_internal/ansible_collections Collection Version ---------------------------------------- ------- ansible._protomatter 2.19.14 # /home/amara/kestrel-infra/collections/ansible_collections Collection Version ---------------------------------------- ------- community.general 13.5.0 community.library_inventory_filtering_v1 1.1.5
ansible._protomatter is an internal collection that
ships with ansible-core 2.19. The other two were installed for this
page (see Ansible Galaxy below); the test
setup keeps collections in the project folder.
Fully qualified collection names
A
fully qualified collection name
(FQCN) identifies a module, plugin or role by its collection and its
own name: ansible.builtin.copy,
community.general.ini_file,
kestrel.platform.nginx_portal. Two collections may both
have a module called ini_file or user; the
FQCN says which one you mean, and it tells a reader which collection
has to be installed.
Short names still work for many modules, which hides a dependency. This playbook sets two options in the billing export's INI file, once with the FQCN and once with the short name:
- name: Configure the billing export
hosts: db01
gather_facts: false
tasks:
- name: Set the export format (fully qualified name)
community.general.ini_file:
path: "{{ kestrel_root }}/etc/kestrel/billing.ini"
section: export
option: format
value: csv
mode: "0640"
- name: Set the export time (short name)
ini_file:
path: "{{ kestrel_root }}/etc/kestrel/billing.ini"
section: export
option: time
value: "02:30"
mode: "0640"
With community.general installed, both tasks work: the
short name ini_file belonged to Ansible itself years
ago, and ansible-core keeps a redirect table that sends it to
community.general.ini_file. Without the collection, the
playbook is rejected before anything runs:
$ ANSIBLE_COLLECTIONS_PATH=/home/amara/kestrel-infra/empty-collections ansible-playbook billing-ini.yml
[WARNING]: Error loading plugin 'community.general.ini_file': No module named 'ansible_collections.community'
[ERROR]: couldn't resolve module/action 'community.general.ini_file'. This often indicates a misspelling, missing collection, or incorrect module path.
Origin: /home/amara/kestrel-infra/ansible/billing-ini.yml:6:7
4
5 tasks:
6 - name: Set the export format (fully qualified name)
^ column 7
The exit status was 4. "couldn't resolve module/action" almost
always means a collection is missing on this control node (or in
this pipeline image), or the name is misspelled. With FQCNs
everywhere, the error names the missing collection directly, and
requirements.yml can be checked against the playbooks.
ansible-core and the ansible package
ansible-core is the Ansible
engine: the command-line tools (ansible,
ansible-playbook, ansible-galaxy,
ansible-doc and others), the playbook language, and the
ansible.builtin collection with the most basic modules
and plugins. The Python package called ansible is
ansible-core plus a curated set of community collections, released
on its own schedule: as of October 2026, ansible 14.4.0
requires ansible-core 2.21. The difference shows in the number of
modules available:
$ ansible-doc -l | wc -l # only ansible-core
$ ansible-doc -l community.general | wc -l # after installing community.general
$ ansible-doc -l | wc -l
71 581 652
Which to install? The full ansible package is
convenient on a workstation for exploring. For anything that matters
— a pipeline, a shared control node — install ansible-core and
exactly the collections your playbooks list in
requirements.yml, at the versions you tested. That
keeps upgrades of the engine and of each collection separate and
deliberate. (Versions and Python requirements are on
Ansible Basics.)
Ansible Galaxy
Ansible Galaxy
(galaxy.ansible.com) is the public hub where the community publishes
Ansible collections and roles, and
ansible-galaxy is the command-line tool that installs
them from Galaxy or from other sources. Installing a collection also
installs the collections it depends on:
$ ansible-galaxy collection install community.general -p /home/amara/kestrel-infra/collections
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-13.5.0.tar.gz to /home/amara/.ansible/tmp/ansible-local-2484421jbtp0y1x/tmp9ankgcc4/community-general-13.5.0-2s8dkybz Installing 'community.general:13.5.0' to '/home/amara/kestrel-infra/collections/ansible_collections/community/general' community.general:13.5.0 was installed successfully Downloading https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-library_inventory_filtering_v1-1.1.5.tar.gz to /home/amara/.ansible/tmp/ansible-local-2484421jbtp0y1x/tmp9ankgcc4/community-library_inventory_filtering_v1-1.1.5-bzrjwvip Installing 'community.library_inventory_filtering_v1:1.1.5' to '/home/amara/kestrel-infra/collections/ansible_collections/community/library_inventory_filtering_v1' community.library_inventory_filtering_v1:1.1.5 was installed successfully
Without a version, ansible-galaxy takes the newest
release (13.5.0 on the day of recording). -p chooses
the target directory; without it, collections go to
~/.ansible/collections. Since ansible-core 2.20, only
servers that speak Galaxy's v3 API are supported, which matters if
your organization runs an old private mirror.
Galaxy is not the only source. Companies with Red Hat Ansible Automation Platform get supported collections from Red Hat's Automation Hub and often run a private hub as an internal mirror; collections and roles can also be installed straight from a Git repository or a tarball. An internal mirror gives you one place to approve what may be used.
requirements.yml and pinning
requirements.yml is the file
in which a project lists the collections and roles it needs, with
their versions and sources, so that
ansible-galaxy install -r requirements.yml gives every
admin and every pipeline run the same content. It is to Ansible what
a dependency manifest such as
composer.json
is to PHP. Kestrel's file pins the one collection it uses:
$ cat requirements.yml
$ ansible-galaxy collection install -r requirements.yml -p /home/amara/kestrel-infra/collections
collections:
- name: community.general
version: "13.5.0"
Starting galaxy collection install process
Nothing to do. All requested collections are already installed. If you want to reinstall them, consider using `--force`.
A fuller file mixes exact pins, version ranges and other sources (this one is illustrative; the Git repositories are fictional):
collections:
- name: community.general
version: ">=13.0.0,<14.0.0" # any 13.x, never 14
- name: community.postgresql
version: "5.0.0" # exactly this version
- name: https://git.example.com/kestrel/ansible-platform.git
type: git
version: v1.4.0 # a Git tag
roles:
- name: billing_export
src: https://git.example.com/kestrel/ansible-role-billing-export.git
scm: git
version: v2.1.0
Collections follow
semantic versioning: a new major version may remove modules or change their behavior,
so a range such as >=13.0.0,<14.0.0
accepts fixes and new features but never a breaking release.
ansible-galaxy installs the newest version that
satisfies every condition. This model shows what different
requirements would install if a server offered these versions:
import operator
OPERATORS = {">=": operator.ge, "<=": operator.le, "!=": operator.ne,
"==": operator.eq, ">": operator.gt, "<": operator.lt}
def parse(version):
return tuple(int(part) for part in version.split("."))
def allowed(version, requirement):
"""Check a version against a requirement such as '>=13.0.0,<14.0.0'."""
for spec in requirement.split(","):
spec = spec.strip()
if spec == "*":
continue
for symbol, compare in OPERATORS.items():
if spec.startswith(symbol):
if not compare(parse(version), parse(spec[len(symbol):])):
return False
break
else: # no operator: an exact version
if parse(version) != parse(spec):
return False
return True
published = ["12.6.0", "13.0.0", "13.4.1", "13.5.0", "14.0.0"] # imagined server
for requirement in ["*", ">=12.0.0", ">=13.0.0,<14.0.0", "13.5.0", ">=13.0.0,!=13.5.0,<14.0.0"]:
matching = [v for v in published if allowed(v, requirement)]
chosen = max(matching, key=parse)
print(f"{requirement:27} installs {chosen}")
* installs 14.0.0 >=12.0.0 installs 14.0.0 >=13.0.0,<14.0.0 installs 13.5.0 13.5.0 installs 13.5.0 >=13.0.0,!=13.5.0,<14.0.0 installs 13.4.1
* and an open lower bound such as
>=12.0.0 would quietly take a future major version.
The != form skips a release known to be broken.
-
Ranges or exact pins? A range picks up fixes
automatically, but two runs a week apart can install different
versions.
ansible-galaxyhas no lock file that records the exact versions it resolved (unlike npm's lock file), so where repeatability matters — production pipelines — pin exact versions and raise them in a reviewed change, after a test run. - Pin Git sources to a tag or commit, never to a branch: a branch moves.
-
Check what is installed.
ansible-galaxy collection verifycompares installed collections with the checksums published on the server, which reveals local edits. - Never edit an installed role or collection in place. The next install overwrites the change. Fork it, contribute the fix upstream, or wrap it in a role of your own.
Designing good roles
-
One job per role.
nginx_portal,postgresql_client,billing_export— not onekestrelrole that does everything. Small roles can be combined in a play; a giant role can only be taken whole. -
Prefix every variable with the role name
(
nginx_portal_port), give each a default indefaults/main.yml, and describe it in the README ormeta/argument_specs.yml. -
Idempotent and check-mode safe. Use state-based
modules, guard every
commandwithcreatesorchanged_when, and notify handlers for restarts. A second run must reportchanged=0. - No secrets and no environment knowledge inside. The role receives values; the inventory and encrypted variable files decide them.
- Version and test it. Tag releases in Git, keep a changelog, and test the role on a throwaway machine — including the second, idempotent run — before a new version reaches production (see Testing and Policy as Code).