Skip to content

Roles & collections

Scope: the course page's ansible-galaxy init roles/nginx scaffold is a single, locally-authored role used by one playbook. This page slows down on how roles get called (three different ways, with different trade-offs), what a collection is and why it sits one level above a role, and how one binary — ansible-galaxy — both scaffolds a role you're authoring and installs one someone else already published.

TL;DR — in 30 seconds:

  • A role is a directory convention (tasks/, handlers/, defaults/, vars/, meta/), not a new language — Ansible loads each subdirectory automatically by location, never by an explicit include:.
  • roles: and import_role: resolve statically at parse time — a when: on import_role: copies onto every task inside instead of skipping the include; only include_role: is dynamic and honors when:/loop: on the call itself.
  • A collection sits one level above a role: a namespaced (namespace.collection) bundle of roles, modules, and plugins that installs as one unit — every ansible.builtin.* module you've called already lives inside one.

1. A role is a directory convention, not a new language

Nothing about a role is a new Ansible concept — it's the same tasks, handlers, templates, and variables you already write, arranged into a fixed set of subdirectories so Ansible loads each one automatically by its location, never by an explicit include:.

Directory Loaded automatically as
tasks/main.yml The role's task list — its entry point
handlers/main.yml Handlers available to anything the role notifies
defaults/main.yml Lowest-precedence variables — meant to be overridden
vars/main.yml Higher-precedence variables — harder for a consumer to override
files/, templates/ Static files and Jinja2 templates the role's tasks reference by relative name
meta/main.yml Role metadata: supported platforms, and dependencies on other roles

A role buys you two things a loose collection of task files doesn't: portability (copy the directory into another repo and it still works, because nothing outside it is required) and implicit wiring (no file has to import: defaults/main.yml — the convention does it).

2. Three ways to call a role — and why it matters which one

- hosts: web
  roles:
    - role: common
    - role: nginx
      vars:
        site_server_name: hiro.example.com

That roles: block is the form you've already seen. It is static: every listed role's tasks are inserted before the play's own tasks: run, regardless of any condition. Two alternatives exist for when that's not what you want:

Form Resolved Can use when: / loop: on the call itself Typical use
roles: (play-level) Parse time, before other tasks No The default — a play's fixed role composition
import_role: Parse time (static) No (the condition applies to every task inside, not the include) Same behavior as roles:, usable mid-tasks:
include_role: Run time (dynamic) Yes Conditionally or repeatedly including a role — e.g. one role per item in a loop:
flowchart TB
    subgraph Static["Static — roles: / import_role:"]
        S1["Parsed once, before/at\nthe include's position"]
        S2["Same on every run"]
    end
    subgraph Dynamic["Dynamic — include_role:"]
        D1["Evaluated when the\nplay actually reaches it"]
        D2["when: / loop: apply\nto the include itself"]
    end

The gotcha this saves you from: putting a when: on an import_role: doesn't skip the include — it gets copied onto every task inside the role, which still runs its own logic per-task. If you need "only include this role at all when X", reach for include_role: instead.

3. Collections — the distribution unit one level above a role

A collection bundles roles, modules, plugins, and playbooks together under one namespaced name — namespace.collection, for example community.general or amazon.aws. Everything you've called with a fully-qualified name like ansible.builtin.package or ansible.posix.authorized_key is a module living inside a collection; ansible.builtin and ansible.posix are collections just like any third-party one.

flowchart LR
    C["Collection\nnamespace.collection"]
    M["Modules"]
    P["Plugins"]
    R["Roles"]
    PB["Playbooks"]
    C --> M
    C --> P
    C --> R
    C --> PB

Two reasons collections exist beyond "a bigger role":

  • Distribution — a collection installs as one unit (ansible-galaxy collection install <namespace.collection>) into a standard search path (./collections/ansible_collections/ or ~/.ansible/collections/), instead of you hand-copying individual role directories around.
  • Namespacing — the FQCN (amazon.aws.ec2_instance) avoids the name collisions that plain module names (ec2_instance) would eventually hit once you install more than one third-party source.

4. requirements.yml — pinning what you pull in

A repo that depends on external roles and collections lists them once, in one file, instead of trusting whatever happens to already be installed on whoever's laptop runs the playbook:

# requirements.yml
collections:
  - name: amazon.aws
    version: ">=7.0.0"
  - name: community.general

roles:
  - name: geerlingguy.nginx
    version: "3.1.0"

Two install commands, one per section — the detail newcomers usually miss, expecting one command to install both:

ansible-galaxy collection install -r requirements.yml
ansible-galaxy role install -r requirements.yml

Committing requirements.yml (and not committing the installed collections//roles/ directories it populates) is what makes "clone the repo, install requirements, run the playbook" reproducible across machines instead of depending on tribal knowledge of what to ansible-galaxy install by hand.

5. Role dependencies — meta/main.yml

A role can declare that another role must run first, every time it's used, without whoever calls it having to remember to list both:

# roles/nginx/meta/main.yml
dependencies:
  - role: common
  - role: firewall
    vars:
      firewall_allowed_ports: [80, 443]
flowchart LR
    Firewall["firewall role"] --> Common["common role"] --> Nginx["nginx role"] --> Play["Play's own tasks"]

Dependencies run once per unique role+parameter combination per play, in the order their owning role is listed in roles: — so a common role that three other roles all depend on runs a single time, not three. Ansible's docs cover allow_duplicates for the rare case you want that dependency to run every time instead.

6. Reuse in practice — where a role should come from

Source Get it with Fits when
Local, in-repo Write it directly under roles/ Logic specific to your environment — the nginx role in the course page
Ansible Galaxy (community) ansible-galaxy role install <author.role> or a requirements.yml entry A well-trodden need (an nginx role, a Postgres role) someone's already hardened
A collection ansible-galaxy collection install <namespace.collection> You need the modules (e.g. amazon.aws.ec2_instance), which may also ship roles and plugins alongside them

ansible-galaxy init roles/<name> scaffolds the directory skeleton for the local-authoring path; the same tool's collection subcommands (ansible-galaxy collection init, collection install) handle the other two. One binary, two different jobs depending on whether you're authoring or consuming.

7. How this connects

The course page's single roles/nginx is the local-authoring path in its simplest form: no dependencies, no external collection, called statically from roles:. This page is what to reach for once you need a role to pull in another role automatically (meta/main.yml), need modules that don't ship with ansible-builtin (a collection), or need the same external role pinned to a version across every clone of the repo (requirements.yml).

Common gotchas

  • Putting when: on an import_role: expecting it to skip the whole role. Fix: it gets copied onto every task inside instead, which still runs its own per-task logic — reach for include_role: when you need "only include this role at all under condition X."
  • Writing a tiny, single-use role "for reuse" with no second caller in sight. Fix: a role under ~30 lines used exactly once is often premature abstraction — the indirection cost isn't paid back yet.
  • Pointing a Git-sourced role/collection at a branch (?ref=main or no ref). Fix: a branch ref means every install can silently pull in whatever's merged since your last run — pin an immutable tag or version instead.
  • Expecting ansible-galaxy install -r requirements.yml to install both roles and collections in one command. Fix: it's two separate commands — ansible-galaxy collection install -r requirements.yml and ansible-galaxy role install -r requirements.yml — one per section.
Check yourself — you add a when: condition to an import_role: call, expecting the whole role to be skipped when the condition is false. What actually happens?

The condition doesn't skip the include — it's applied to every individual task inside the role, which still evaluates its own logic per-task at parse time. To conditionally include a role at all, use include_role: instead, which resolves dynamically and honors when: on the call itself.

Check yourself — your repo's requirements.yml lists both collections: and roles:. You run ansible-galaxy collection install -r requirements.yml and the playbook still can't find geerlingguy.nginx. Why?

Collections and roles install with two separate commands — ansible-galaxy collection install -r requirements.yml only installs the collections: section; the roles: section needs its own ansible-galaxy role install -r requirements.yml run.

You can defend this when you can say why import_role: and include_role: behave differently under a when: condition, explain what problem a collection solves that a role alone doesn't, and describe what requirements.yml actually reproduces (pinned external roles/collections) versus what it doesn't (your own in-repo roles, which are already in the clone).