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_role is static: Ansible reads the role's tasks when it loads the playbook, so they show up in --list-tasks, and a when or tags on the import is copied to every task inside.
  • include_role is 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; a when applies to the include itself, and tags on it do not reach the tasks inside unless you pass them on with apply.

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-galaxy has 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 verify compares 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 one kestrel role 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 in defaults/main.yml, and describe it in the README or meta/argument_specs.yml.
  • Idempotent and check-mode safe. Use state-based modules, guard every command with creates or changed_when, and notify handlers for restarts. A second run must report changed=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).