Up to now you have written plays — long lists of tasks, handlers, and variables in a single file. That works for one machine and one job, but it does not scale: you cannot share it, you cannot version it, you copy-paste the same “install and configure nginx” block into every project, and the playbook becomes a thousand-line wall nobody wants to read. Roles are Ansible’s answer. A role is a self-contained, reusable unit of automation — a fixed directory layout that holds the tasks, handlers, templates, files, variables, defaults, and metadata for one logical job (“be a web server”, “harden SSH”, “deploy our app”). Drop a role into any playbook with one line and Ansible knows where everything lives because the directory structure is a contract.
Collections are the next layer up. A collection is a distributable bundle — namespace.collection — that can contain many roles, plus modules, plugins, and documentation, all versioned and installable from Ansible Galaxy (the public hub) or a private Automation Hub. Since Ansible 2.10 almost every module you use actually lives in a collection and is addressed by its FQCN (fully-qualified collection name, like ansible.builtin.copy or community.general.ufw). Roles are how you organise your automation; collections are how the whole ecosystem ships everyone’s automation — and requirements.yml is the manifest that pulls both into your project reproducibly.
This lesson is the exhaustive version. By the end you will know every directory in a role and the main.yml auto-loading rules; the three ways to invoke a role (the roles: keyword, ansible.builtin.import_role, and ansible.builtin.include_role) and exactly how static import differs from dynamic include; how to pass role parameters and run a non-default entry point with tasks_from; how role dependencies work in meta/main.yml and what allow_duplicates does; the variable precedence rules that decide whether defaults/ or vars/ wins; how to use Ansible Galaxy (ansible-galaxy role install, ansible-galaxy collection install, ansible-galaxy init); what a collection is, the FQCN, the collections: keyword, and collections_path; and how to write a requirements.yml that installs roles and collections from Galaxy, git, and URLs with pinned versions. Every option gets the same treatment — what it is · the choices · the default · when to use it · the trade-off · the gotcha — and every operation comes with a real command. Everything reflects current ansible-core 2.17+ / Ansible 10+ (2026).
Learning objectives
By the end of this lesson you can:
- Lay out a role in the standard directory structure and explain the auto-loading rule for each
main.yml. - Invoke a role three ways — the
roles:keyword,import_role(static), andinclude_role(dynamic) — and choose correctly between them. - Pass role parameters, run an alternate entry point with
tasks_from, and apply tags/conditions to a role. - Declare role dependencies in
meta/main.yml, control re-runs withallow_duplicates, and order dependent roles correctly. - Reason about role variable precedence — why
defaults/main.ymlis overridable butvars/main.ymlis not. - Use Ansible Galaxy to scaffold (
init), install, and list roles and collections. - Address modules by FQCN, use the
collections:keyword, setcollections_path, and pin everything in arequirements.yml(Galaxy, git, URL, versions).
Prerequisites & where this fits
You should already be comfortable writing a playbook — a play with hosts, become, tasks, handlers, and vars — and using variables and facts (especially the precedence idea) and notify/handlers. If register, when, loop, or import_tasks/include_tasks are fuzzy, skim the earlier playbook and variables lessons first. In the Ansible Zero-to-Hero programme this is the lesson that turns “a playbook on my laptop” into “shareable, versioned, dependency-managed automation a whole team can reuse.” It follows the error-handling lesson (your roles should be resilient) and leads into Ansible Vault (roles need secrets) and the deeper idempotent collections with Molecule testing (proving a role is correct in CI). Think of a role as the noun — the reusable thing — and a playbook as the sentence that arranges several nouns into a deployment.
Core concepts
A role is a directory whose name is the role name and whose subdirectories have fixed, reserved names. Ansible does not need a config file inside a role telling it what is what — the location of a file is its meaning. Put tasks in tasks/main.yml and they are the role’s tasks; put a file in templates/ and ansible.builtin.template finds it by bare filename. This convention-over-configuration design is the whole point: any role looks like any other role, so you can read and reuse strangers’ roles instantly.
A collection is a higher-level package identified by namespace.collection (for example ansible.builtin, community.general, amazon.aws, or your own kloudvin.platform). A collection can ship modules, plugins, roles, and playbooks together, versioned with semantic versioning, and is installed into a collections path as a directory tree ansible_collections/<namespace>/<collection>/. The crucial consequence is the FQCN: every module, role, or plugin is addressed as namespace.collection.name. ansible.builtin.copy means the copy module from the builtin collection in the ansible namespace; community.general.ufw means the ufw module from community.general. Using FQCNs everywhere removes ambiguity (two collections can both define a firewall module) and is now best practice and what the linters enforce.
Ansible Galaxy (galaxy.ansible.com) is the public registry for both standalone roles (the older, role-only distribution model) and collections (the modern bundle). The ansible-galaxy CLI talks to Galaxy (or a private server) to search, install, and scaffold roles and collections. A requirements.yml file lists the roles and collections your project depends on — with their sources (Galaxy, a git URL, a tarball URL) and versions — so ansible-galaxy install -r requirements.yml reproduces the exact dependency set on any machine or CI runner.
Keep these distinctions clear, because the rest of the lesson is built on them: role (a directory of automation), standalone role vs collection (two distribution formats), FQCN (namespace.collection.name addressing), roles: vs import_role vs include_role (three ways to run a role, static vs dynamic), and requirements.yml (the dependency manifest).
The role directory structure: every directory
A role is found by name under a roles path (covered later) and has this fixed layout. Scaffold the skeleton with ansible-galaxy init <role-name>, which creates exactly these directories:
roles/
└── webserver/ # the role name
├── tasks/
│ └── main.yml # the role's tasks (the default entry point)
├── handlers/
│ └── main.yml # handlers the tasks can notify
├── templates/
│ └── nginx.conf.j2 # Jinja2 templates (found by bare name)
├── files/
│ └── index.html # static files to copy (found by bare name)
├── vars/
│ └── main.yml # high-precedence role variables
├── defaults/
│ └── main.yml # lowest-precedence, overridable variables
├── meta/
│ └── main.yml # role metadata, dependencies, galaxy_info
├── library/ # role-local custom modules (optional)
├── module_utils/ # shared Python for those modules (optional)
├── lookup_plugins/ # role-local plugins (optional, any plugin type)
└── README.md # documentation
Each directory has a precise job, a precise auto-loading behaviour, and a precise precedence position. This is the table to memorise.
| Directory | main.yml auto-loaded? |
What it holds | How tasks reference it | Notes / gotcha |
|---|---|---|---|---|
tasks/ |
Yes — tasks/main.yml is the default entry point run when the role is invoked |
The list of tasks | n/a (this is the tasks) | Other files here (e.g. tasks/install.yml) are run only via tasks_from or import_tasks/include_tasks. |
handlers/ |
Yes — handlers/main.yml is loaded automatically |
Handlers (notified by tasks) | notify: "Restart nginx" |
Handler names are global within the play; notify by name. |
templates/ |
n/a (not a YAML file) | .j2 Jinja2 templates |
ansible.builtin.template: src: nginx.conf.j2 — bare filename, no path |
Ansible searches templates/ automatically; templates can be referenced by bare name from inside the role. |
files/ |
n/a | Static files & scripts | ansible.builtin.copy: src: index.html — bare filename |
Same magic search: copy/script look in files/ first. |
vars/ |
Yes — vars/main.yml loaded automatically |
Role variables (high precedence) | referenced like any var: {{ my_var }} |
High in precedence — not meant to be overridden by the caller. Put internal constants here. |
defaults/ |
Yes — defaults/main.yml loaded automatically |
Default variables (lowest precedence) | {{ my_var }} |
Lowest precedence of all named vars — the role’s public, overridable API. Put every tunable here. |
meta/ |
Yes — meta/main.yml loaded automatically |
Dependencies, galaxy_info, supported platforms, allow_duplicates |
n/a | Also where argument_specs.yml (typed input validation) lives. |
library/ |
n/a | Role-local custom modules | callable by name within the role | Lets a role ship a module without a full collection. |
module_utils/ |
n/a | Shared Python imported by those modules | Python import | For deduplicating code across the role’s modules. |
<type>_plugins/ |
n/a | Role-local plugins (filter_plugins/, lookup_plugins/, vars_plugins/, …) |
by plugin name | Auto-loaded when the role is used; one dir per plugin type. |
Three rules tie this together:
main.ymlis the entry point in every directory that holds YAML.tasks/main.yml,handlers/main.yml,vars/main.yml,defaults/main.yml, andmeta/main.ymlare loaded automatically when the role runs — you never name them explicitly. A role with onlytasks/main.ymlis a perfectly valid role; every other directory is optional.templates/andfiles/enable bare-name references. Inside a role,ansible.builtin.template: src: app.conf.j2resolves toroles/<role>/templates/app.conf.j2automatically, andcopy: src: logo.pngresolves toroles/<role>/files/logo.png. This relative search path is one of the biggest ergonomic wins of roles — no long paths.defaults/is lowest precedence;vars/is high precedence. This single fact is the most-tested role concept (full table below): variables a caller should be able to override go indefaults/; values the role author wants to protect go invars/.
You do not need every directory. The minimal useful role is just tasks/main.yml. As the role grows you add defaults/ (its tunables), handlers/ (its restarts), templates//files/ (its content), and meta/main.yml (its dependencies and Galaxy metadata).
Using a role: the roles: keyword vs import_role vs include_role
There are three ways to run a role, and the difference between them is the difference between static and dynamic — exactly the same distinction as import_tasks (static) versus include_tasks (dynamic). Getting this right is core RHCE material.
| Method | Static or dynamic? | Where it runs | Accepts parameters? | when/loop behaviour |
Tags behaviour |
|---|---|---|---|---|---|
roles: keyword |
Static (pre-processed before the play runs) | Runs before tasks: (the role’s tasks come first), in roles: order |
Yes (inline params) | A when on the entry applies to every task in the role |
Tags are added to all the role’s tasks at parse time |
ansible.builtin.import_role |
Static (imported at parse time) | Wherever the task sits in tasks: |
Yes (via vars:) |
when/loop apply to each task individually (cannot loop the whole role) |
Inherited statically by all tasks |
ansible.builtin.include_role |
Dynamic (resolved at runtime) | Wherever the task sits, at run time | Yes (via vars:) |
when/loop apply to the include as a unit — you can loop a dynamic role include |
Tags must be on the include_role task itself; inner tags are not exposed to --tags |
The roles: keyword
The classic, declarative form. List the roles a play applies; Ansible runs each role’s tasks/main.yml before the play’s own tasks: section, in the order listed (after running their dependencies):
- name: Configure web tier
hosts: web
become: true
roles:
- common # shorthand: just the role name
- role: webserver # long form, lets you add options
vars:
webserver_port: 8080 # a role parameter
tags: [web]
when: ansible_os_family == "RedHat"
- geerlingguy.nginx # a Galaxy role by full name
Key facts about roles:: it is static (the roles are merged into the play at parse time, before any task runs), the roles run before anything in tasks:, and a when: on a role entry is copied onto every task the role contains. There is also a pre_tasks / roles / tasks / post_tasks ordering for a play — pre_tasks run first, then roles, then tasks, then post_tasks; handlers notified by pre_tasks/roles flush between sections.
import_role (static)
ansible.builtin.import_role pulls a role in at a specific point in your tasks: list, and does it statically (parsed up front, like roles:):
tasks:
- name: Do some setup first
ansible.builtin.debug:
msg: "preparing"
- name: Now bring in the webserver role at this exact point
ansible.builtin.import_role:
name: webserver
vars:
webserver_port: 8080 # role parameters go under vars:
- name: Continue afterwards
ansible.builtin.service:
name: nginx
state: started
Because it is static, import_role:
- is parsed before the play runs, so the role’s tasks are visible to
--list-tasks,--start-at-task, and--tagsas individual tasks; - cannot be looped as a whole (a
looponimport_roleerrors — the import happens once at parse time); - a
when:on theimport_roleis applied to each task individually inside the role (not to the role as one gate).
Use import_role when you want the role’s tasks woven into a precise position and fully visible to task-level CLI controls.
include_role (dynamic)
ansible.builtin.include_role brings a role in at run time — the role is resolved when execution reaches the task, not at parse time:
tasks:
- name: Conditionally include the role only on RHEL
ansible.builtin.include_role:
name: webserver
vars:
webserver_port: 8080
when: ansible_os_family == "RedHat" # gates the WHOLE include as one unit
- name: Apply a role once per item (loops work with include_role!)
ansible.builtin.include_role:
name: create_vhost
loop: "{{ vhosts }}"
loop_control:
loop_var: vhost
Because it is dynamic, include_role:
- is resolved at run time, so its inner tasks are not visible to
--list-tasksahead of time and--start-at-taskcannot jump inside it; - can be looped — the entire role runs once per loop item (impossible with
import_role); - a
when:(orloop) applies to the include as a single unit; - requires tags on the
include_roletask itself;--tagswill not match tags defined only inside the dynamically-included role (a classic gotcha).
The mental model is identical to tasks: import = static = “paste it in at parse time” (visible, no looping the unit); include = dynamic = “decide at run time” (loopable, conditional as a unit, but opaque to pre-run listing). Prefer import_role/the roles: keyword for the common case; reach for include_role when you genuinely need to loop a role or include it conditionally as a unit.
Role parameters and tasks_from
You pass variables into a role as role parameters. With the roles: long form and with import_role/include_role you put them under vars::
- ansible.builtin.include_role:
name: webserver
tasks_from: install # run tasks/install.yml instead of tasks/main.yml
handlers_from: extra # (optional) load handlers/extra.yml too
vars_from: rhel # (optional) load vars/rhel.yml
defaults_from: minimal # (optional) load defaults/minimal.yml
vars:
webserver_port: 8443
tasks_from is the important one: a role can have multiple entry points. Instead of one giant tasks/main.yml, you split work into tasks/install.yml, tasks/configure.yml, tasks/upgrade.yml, and call a specific one with tasks_from: upgrade. This is how mature roles (and most Galaxy “collection roles”) expose several operations from one role without a separate role per operation. The matching handlers_from, vars_from, and defaults_from let you load alternate handler/var/default files alongside the chosen task file. All of these work with both import_role and include_role.
Role dependencies and allow_duplicates
A role can declare that it depends on other roles in its meta/main.yml. Those dependency roles run before the depending role’s tasks/main.yml, automatically:
# roles/webserver/meta/main.yml
---
galaxy_info:
author: Vinod H
description: Install and configure nginx
license: MIT
min_ansible_version: "2.16"
platforms:
- name: EL
versions: ["9"]
- name: Ubuntu
versions: ["jammy", "noble"]
galaxy_tags: [web, nginx]
dependencies:
- role: common # run the common role first
- role: firewall # then firewall
vars:
firewall_allowed_tcp_ports: [80, 443] # pass params to the dependency
The meta/main.yml keys you care about:
| Key | What it does | Notes |
|---|---|---|
dependencies |
A list of roles that must run before this role’s tasks | Each entry is a role name or {role: x, vars: {...}}; dependencies of dependencies resolve recursively. |
galaxy_info |
Metadata for Galaxy: author, description, license, min_ansible_version, platforms, galaxy_tags |
Required to publish a standalone role; platforms drives Galaxy search filters. |
allow_duplicates |
Whether the role may run more than once in a single play | Default false — a role with the same parameters is deduplicated (runs once). Set true to allow repeated runs. |
The allow_duplicates behaviour is the subtle, exam-favourite part. By default, if the same role is pulled in twice with identical parameters during one play (commonly because two roles both depend on common), Ansible runs it only once — it deduplicates. That is what you want for a base/common role: it should not run five times just because five roles depend on it. But sometimes a role is meant to run repeatedly (e.g. a create_user role you want to invoke per user via dependencies with different params). Two facts:
- A role invoked with different parameters is not treated as a duplicate — it runs again. Deduplication only skips a role when the name and parameters match a previous run.
- To force a role to run again even with the same parameters, set
allow_duplicates: truein that role’smeta/main.yml.
# roles/create_user/meta/main.yml
---
allow_duplicates: true # let this role run multiple times in one play
Dependency ordering rule to remember: dependencies run before the role that needs them, and a shared dependency runs once (first time it is encountered) unless params differ or allow_duplicates: true.
Role variable precedence: defaults vs vars
Roles introduce two of the most important rungs on Ansible’s ~22-level variable-precedence ladder, and the gap between them is the single most common interview probe about roles. Both defaults/main.yml and vars/main.yml are auto-loaded, but they sit at opposite ends of precedence.
| Rung (low → high, abridged) | Source | Overridable by the caller? |
|---|---|---|
| Lowest | role defaults/main.yml |
Yes — by almost anything: inventory vars, group_vars, host_vars, play vars, role params, set_fact, -e. |
| … | inventory group_vars / host_vars, play vars, set_fact, registered vars |
— |
| Higher | role vars/main.yml |
Only by block/task vars and extra-vars (-e). |
| Higher still | block vars → task vars | — |
| Highest | extra-vars (-e / --extra-vars) |
Beats everything, including vars/. |
The practical rules that follow:
- Put every tunable in
defaults/main.yml. This is the role’s public API — the knobs a user is expected to override (webserver_port,app_version,nginx_worker_processes). Because defaults are the lowest precedence, a caller can override them from group_vars, the play, a role parameter, or-ewithout editing the role. - Put internal constants in
vars/main.yml. Values the role author controls and does not want a casual caller to clobber — package names per OS, internal file paths, lookup maps. Becausevars/is high precedence, it is hard (only block/task vars or-e) to override, which is exactly the protection you want. - Role parameters (passed via
roles: … vars:orimport_role/include_rolevars:) sit abovedefaults/and above inventory/group/host vars — so a parameter you pass at call time beats the role’s default and beats group_vars. This is why parameters are the clean way to customise a role per-invocation. -eextra-vars beats everything, includingvars/main.yml. Use it for one-off CI overrides; never rely on it for normal configuration.
A useful way to remember it: defaults/ = “suggestions you can ignore”; vars/ = “house rules”; -e = “the boss said so”.
Ansible Galaxy: roles and collections
Ansible Galaxy is the public hub at galaxy.ansible.com, and ansible-galaxy is the CLI that installs from it (or from a private Galaxy NG / Automation Hub you configure). It deals in two distribution formats — older standalone roles and modern collections — and the CLI has a subcommand group for each (ansible-galaxy role … and ansible-galaxy collection …).
Standalone roles vs collections
| Standalone role | Collection | |
|---|---|---|
| Unit | One role | A bundle of many roles + modules + plugins + playbooks |
| Identifier | namespace.rolename (e.g. geerlingguy.nginx) |
namespace.collection (e.g. community.general) |
| Installed into | roles_path (default ~/.ansible/roles, /etc/ansible/roles) |
collections_path as ansible_collections/<ns>/<coll>/ |
| Versioning | git tags (Galaxy resolves) | semantic versioning in galaxy.yml |
| Modern? | Legacy but still supported and widely used | The current standard — modules now ship only in collections |
| Install command | ansible-galaxy role install |
ansible-galaxy collection install |
Collections are the strategic direction (a collection can contain roles), but standalone roles remain extremely common on Galaxy (geerlingguy’s roles, for instance), so you must handle both.
Scaffolding with ansible-galaxy init
Create the empty skeleton for a new role or collection:
# Scaffold a standalone role (creates the full directory tree shown earlier)
ansible-galaxy role init webserver
ansible-galaxy init webserver # 'role' is the default; same thing
# Scaffold a collection (namespace.collection layout with galaxy.yml, plugins/, roles/)
ansible-galaxy collection init kloudvin.platform
role init produces every directory from the structure table (with stub main.yml files and a meta/main.yml containing galaxy_info). collection init produces galaxy.yml (the collection manifest), plugins/, roles/, docs/, and meta/runtime.yml.
Installing roles
# Install one role from Galaxy
ansible-galaxy role install geerlingguy.nginx
# Pin a version (Galaxy resolves to a git tag/release)
ansible-galaxy role install geerlingguy.nginx,3.1.4
# Install into a specific path (not the default roles_path)
ansible-galaxy role install geerlingguy.nginx -p ./roles
# Force-reinstall / overwrite an existing role
ansible-galaxy role install geerlingguy.nginx --force
# List and remove installed roles
ansible-galaxy role list
ansible-galaxy role remove geerlingguy.nginx
Installing collections
# Install a collection from Galaxy (latest)
ansible-galaxy collection install community.general
# Pin a version
ansible-galaxy collection install community.general:==8.6.0
# Install into the project (recommended for repeatable projects)
ansible-galaxy collection install community.general -p ./collections
# Force, and list what is installed (and where)
ansible-galaxy collection install community.general --force
ansible-galaxy collection list
| Common flag | Applies to | Effect |
|---|---|---|
-r requirements.yml |
both | Install everything listed in a requirements file (the reproducible way). |
-p PATH |
both | Install into PATH instead of the default roles/collections path. |
--force / -f |
both | Re-download and overwrite an already-installed version. |
--force-with-deps |
collections | Force-reinstall the collection and its dependencies. |
,VERSION (roles) / :VERSION (collections) |
both | Pin a version on the command line. |
-s SERVER |
both | Use a specific Galaxy server (e.g. private Automation Hub). |
Collections, the FQCN, and the collections: keyword
Since Ansible 2.10, the monolithic module set was split into collections. ansible-core ships only the ansible.builtin collection (the truly core modules: copy, file, template, service, package, debug, setup, …). Everything else — cloud modules, community.general, ansible.posix, amazon.aws, azure.azcollection — lives in separately-installed collections. This is why FQCN matters.
FQCN — always say namespace.collection.name
tasks:
- name: Copy a file (core module — fully qualified)
ansible.builtin.copy:
src: app.conf
dest: /etc/app.conf
- name: Manage a firewall rule (community module — fully qualified)
community.general.ufw:
rule: allow
port: "443"
proto: tcp
- name: Use a role from a collection by its FQCN
ansible.builtin.include_role:
name: kloudvin.platform.nginx
Why FQCN is best practice (and linter-enforced): two collections can each define a module called firewall or a role called nginx; the FQCN removes the ambiguity and makes the playbook self-documenting about where each module comes from. Roles inside a collection are addressed the same way: namespace.collection.rolename.
The collections: keyword (the search-path shortcut)
If you do not want to type the namespace on every task, the collections: keyword sets a search order of collections for unqualified names within that play or role:
- name: Web tier
hosts: web
collections:
- community.general # search here for unqualified module/role names
- ansible.posix
tasks:
- name: 'ufw' resolves to community.general.ufw via the search list
ufw:
rule: allow
port: "80"
The collections: keyword can be set at play level and inside a role (in meta/main.yml as collections:). Two important caveats:
- It only affects unqualified names;
ansible.builtinis always searched as a fallback so core modules keep working. - It is a convenience for backward compatibility, but the modern recommendation is to use FQCNs explicitly rather than rely on
collections:— explicit FQCNs are unambiguous and whatansible-lint’s production profile expects. Knowcollections:exists (it appears in older playbooks and in exam questions), but reach for FQCNs.
collections_path — where collections are installed and found
Ansible looks for collections in the collections path, configured in ansible.cfg (or ANSIBLE_COLLECTIONS_PATH):
# ansible.cfg
[defaults]
collections_path = ./collections:~/.ansible/collections:/usr/share/ansible/collections
roles_path = ./roles:~/.ansible/roles:/etc/ansible/roles
| Setting | Default search order | Project-local recommendation |
|---|---|---|
collections_path |
~/.ansible/collections → /usr/share/ansible/collections |
Put ./collections first and install there with -p ./collections so the project is self-contained and CI-reproducible. |
roles_path |
~/.ansible/roles → /etc/ansible/roles (and the playbook’s adjacent roles/) |
Put ./roles first; a roles/ directory next to your playbook is auto-searched even without config. |
Within any of these paths, a collection physically lives at ansible_collections/<namespace>/<collection>/. This layout is also why CI checks a collection repo out into ansible_collections/<ns>/<name>/ — Ansible only resolves namespace.collection.* references when the tree sits at that path.
requirements.yml: the dependency manifest
requirements.yml is how you declare exactly which roles and collections a project needs, from where, and at which version — so any teammate or CI runner reproduces the same set with one command. It has two top-level keys: roles: and collections:.
# requirements.yml
---
roles:
# 1. A standalone role from Galaxy, pinned to a version
- name: geerlingguy.nginx
version: "3.1.4"
# 2. A role from a git repository (any git host), pinned to a tag/branch/commit
- src: https://github.com/kloudvin/ansible-role-hardening.git
scm: git
version: "v2.3.0" # a tag, branch, or commit SHA
name: hardening # the local name to install it as
# 3. A role from a tarball URL
- src: https://example.com/roles/monitoring-1.0.tar.gz
name: monitoring
collections:
# 4. A collection from Galaxy with a version range (gets patches, not majors)
- name: community.general
version: ">=8.0.0,<9.0.0"
# 5. A collection from Galaxy pinned exactly
- name: ansible.posix
version: "1.5.4"
# 6. A collection straight from git
- name: https://github.com/kloudvin/platform-collection.git
type: git
version: main
# 7. A collection from a built tarball
- name: ./build/kloudvin-platform-1.4.0.tar.gz
type: file
The fields, per source type:
| Key | Applies to | Meaning | Notes |
|---|---|---|---|
name |
roles & collections | The Galaxy name (ns.role / ns.collection) or the local install name |
For roles from git, name is what it installs as. |
src |
roles | The source: a Galaxy name, a git URL, or a tarball URL | If it is a git/HTTP URL, set scm. |
scm |
roles | git (or hg) when src is a repo URL |
Defaults to assuming a Galaxy name if omitted. |
version |
both | A tag, branch, commit SHA, exact version, or a range (>=8.0.0,<9.0.0) |
Pin in production for reproducibility; ranges with a major ceiling get fixes but no breaking changes. |
type |
collections | galaxy (default), git, file, url, dir, subdirs |
The collection equivalent of a role’s scm/src distinction. |
Install everything in the file:
# Install BOTH roles and collections from the same file
ansible-galaxy install -r requirements.yml
# Or restrict to one type
ansible-galaxy role install -r requirements.yml
ansible-galaxy collection install -r requirements.yml
# Project-local, the reproducible pattern:
ansible-galaxy collection install -r requirements.yml -p ./collections
ansible-galaxy role install -r requirements.yml -p ./roles
Two facts that matter for exams and for real CI:
ansible-galaxy install -r requirements.yml(norole/collectionsubcommand) installs both sections — the convenient one-shot.- A collection’s own dependencies (declared in its
galaxy.yml) are resolved and installed automatically; you do not list a collection’s transitive collection-deps yourself.
The diagram shows one project’s resolution flow: a requirements.yml pulls roles (from Galaxy and git) and collections (from Galaxy) into roles/ and ansible_collections/; a play invokes a role via the roles: keyword, import_role (static), or include_role (dynamic, loopable); the role’s meta/main.yml runs its dependencies first; defaults/ sits below caller overrides while vars/ sits above them; and every module/role is addressed by its FQCN resolved against collections_path.
Hands-on lab
This lab builds a real role from scratch on your control node + localhost (no cloud, no cost), wires up a dependency, proves the static-vs-dynamic difference, installs a Galaxy role and a collection via requirements.yml, and cleans up. It uses only ansible-galaxy and ansible-playbook against localhost — ₹0. If you want a “real” target, an optional step uses a throwaway container; otherwise everything runs on the connection-local host.
0. Set up a project directory and a local-only inventory.
mkdir -p ~/ansible-roles-lab/roles && cd ~/ansible-roles-lab
cat > ansible.cfg <<'EOF'
[defaults]
inventory = ./inventory.ini
roles_path = ./roles:~/.ansible/roles
collections_path = ./collections:~/.ansible/collections
stdout_callback = default
EOF
cat > inventory.ini <<'EOF'
[local]
localhost ansible_connection=local
EOF
1. Scaffold a role with ansible-galaxy init.
ansible-galaxy role init roles/greeting
ls -R roles/greeting
Expected: the full directory tree — tasks/ handlers/ templates/ files/ vars/ defaults/ meta/ each with a stub main.yml, plus README.md. This is the structure from the table, generated for you.
2. Give the role tunables (defaults), constants (vars), a template, and tasks.
cat > roles/greeting/defaults/main.yml <<'EOF'
---
greeting_name: "world" # overridable: the role's public API
greeting_file: "/tmp/greeting.txt"
EOF
cat > roles/greeting/vars/main.yml <<'EOF'
---
greeting_internal_prefix: "MANAGED-BY-ANSIBLE" # house rule, not for casual override
EOF
cat > roles/greeting/templates/greeting.j2 <<'EOF'
{{ greeting_internal_prefix }}: Hello, {{ greeting_name }}!
EOF
cat > roles/greeting/tasks/main.yml <<'EOF'
---
- name: Render the greeting file
ansible.builtin.template:
src: greeting.j2 # bare name: found in templates/ automatically
dest: "{{ greeting_file }}"
mode: "0644"
notify: Show greeting
- name: Also provide an alternate entry point (tasks_from target)
ansible.builtin.debug:
msg: "main.yml ran"
EOF
cat > roles/greeting/tasks/cleanup.yml <<'EOF'
---
- name: Remove the greeting file (alternate entry point)
ansible.builtin.file:
path: "{{ greeting_file }}"
state: absent
EOF
cat > roles/greeting/handlers/main.yml <<'EOF'
---
- name: Show greeting
ansible.builtin.command: "cat {{ greeting_file }}"
changed_when: false
EOF
3. Run the role three ways in one playbook to feel static vs dynamic.
cat > site.yml <<'EOF'
---
- name: Demonstrate role invocation
hosts: local
gather_facts: false
roles:
- role: greeting # (a) roles: keyword — runs FIRST, before tasks:
vars:
greeting_name: "from roles-keyword"
tasks:
- name: (b) Static import at this exact point
ansible.builtin.import_role:
name: greeting
vars:
greeting_name: "from import_role"
- name: (c) Dynamic include, looped (impossible with import_role)
ansible.builtin.include_role:
name: greeting
vars:
greeting_name: "{{ item }}"
loop:
- "loop-A"
- "loop-B"
- name: (d) Call the ALTERNATE entry point via tasks_from
ansible.builtin.include_role:
name: greeting
tasks_from: cleanup
EOF
ansible-playbook site.yml
Expected: the roles: greeting runs before the tasks: (note its output appears first in the recap order), import_role runs at its position, the looped include_role runs twice (A then B), and the final include runs cleanup.yml (deleting the file). Validate the precedence/override behaviour:
# defaults/ value overridden by a role parameter:
ansible-playbook site.yml --tags never 2>/dev/null; cat /tmp/greeting.txt 2>/dev/null
# Override via extra-vars beats BOTH default and parameter for that run:
ansible-playbook site.yml -e greeting_name="from-extra-vars"
Expected: with -e, the rendered greeting says from-extra-vars — proving -e outranks the role parameter and the default.
4. Add a dependency and prove allow_duplicates.
ansible-galaxy role init roles/common
cat > roles/common/tasks/main.yml <<'EOF'
---
- name: Common base task
ansible.builtin.debug:
msg: "common ran"
EOF
# greeting now depends on common:
cat > roles/greeting/meta/main.yml <<'EOF'
---
dependencies:
- role: common
EOF
# Invoke greeting twice; 'common' should run only ONCE (dedup):
cat > deps.yml <<'EOF'
---
- hosts: local
gather_facts: false
roles:
- greeting
- greeting
EOF
ansible-playbook deps.yml | grep -c "common ran"
Expected: 1 — the shared dependency common is deduplicated even though greeting is listed twice. Now allow duplicates on common:
printf -- "---\nallow_duplicates: true\n" > roles/common/meta/main.yml
ansible-playbook deps.yml | grep -c "common ran"
Expected: 2 — with allow_duplicates: true, the dependency runs each time.
5. Install a Galaxy role and a collection via requirements.yml. (Needs internet; skip if offline.)
cat > requirements.yml <<'EOF'
---
roles:
- name: geerlingguy.git # a small, well-known standalone role
version: "3.0.0"
collections:
- name: community.general
version: ">=8.0.0,<9.0.0"
EOF
ansible-galaxy install -r requirements.yml -p ./roles # roles -> ./roles
ansible-galaxy collection install -r requirements.yml -p ./collections
ansible-galaxy role list
ansible-galaxy collection list | head
Expected: geerlingguy.git appears under ./roles, and community.general (a >=8,<9 version) under ./collections/ansible_collections/community/general.
6. Use a collection module by FQCN to confirm resolution from the local collections_path:
ansible localhost -m community.general.timezone -a "name=UTC" --check
Expected: a check-mode result (no change made) — proving Ansible resolved community.general.timezone from your project-local collections path.
Cleanup (remove everything the lab created):
ansible-playbook -e greeting_file=/tmp/greeting.txt -i inventory.ini \
-m ansible.builtin.file -a "path=/tmp/greeting.txt state=absent" all 2>/dev/null
rm -rf ~/ansible-roles-lab
Expected: the lab directory (roles, installed Galaxy content, the /tmp/greeting.txt) is gone.
Cost note: ₹0. Everything ran against localhost with the local connection — no managed nodes, no cloud, no containers required. The only network usage is the optional ansible-galaxy install in steps 5–6 (a few MB of downloads). Deleting ~/ansible-roles-lab returns the machine to its prior state.
Common mistakes & troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
ERROR! the role 'webserver' was not found |
Role not on the roles_path (typo, or not installed, or wrong dir name) |
Confirm roles/webserver/ exists or ansible-galaxy role install it; check roles_path in ansible.cfg. |
| Template/file “not found” though it is in the role | Referenced with a path instead of a bare name, or file is outside templates//files/ |
Use the bare filename (src: app.conf.j2); Ansible searches the role’s templates//files/ automatically. |
Variable I set in vars/main.yml “won’t override” from group_vars |
vars/ is high precedence; group_vars cannot beat it |
Move the tunable to defaults/main.yml (lowest precedence) so callers can override it. |
--tags web skips tasks inside an include_role |
include_role is dynamic; inner tags aren’t exposed to --tags |
Put the tag on the include_role task itself, or use import_role (static) so inner tasks inherit tags. |
loop on import_role errors |
import_role is static and cannot be looped as a unit |
Use include_role with loop: to run a role per item. |
| A shared dependency role runs many times unexpectedly | The role has allow_duplicates: true, or it’s invoked with different parameters each time |
Remove allow_duplicates (default dedups identical-param runs); or accept it if different params genuinely need separate runs. |
couldn't resolve module/action 'ufw' |
Collection not installed, or name not qualified and not on the collections: search list |
ansible-galaxy collection install community.general; reference it as community.general.ufw (FQCN). |
| FQCN works locally but “collection not found” in CI | Repo/collection not laid out at ansible_collections/<ns>/<name>/, or collections_path wrong |
Install with -p ./collections (creates the right tree) and set collections_path to include ./collections. |
Best practices
- One role = one job. A role should do one logical thing (“be a web server”, “harden SSH”). Compose deployments by listing several focused roles, not by writing one mega-role.
- Put every tunable in
defaults/, every constant invars/. Defaults are your role’s overridable public API;vars/holds values you do not want callers to clobber. This split is the most important habit for reusable roles. - Prefer FQCNs everywhere (
ansible.builtin.copy,community.general.ufw) over bare names or thecollections:keyword. It is unambiguous, self-documenting, and whatansible-lint’s production profile enforces. - Default to
roles:/import_role(static); useinclude_roleonly when you must loop or conditionally include a whole role. Static keeps tasks visible to--list-tasks,--start-at-task, and--tags. - Pin versions in
requirements.yml— exact versions (or a>=x,<x+1range) for collections and git tags/SHAs for roles — so installs are reproducible across machines and CI. - Install project-local (
-p ./roles,-p ./collections) and commit therequirements.yml, not the downloaded content. CI runsansible-galaxy install -r requirements.ymlto hydrate. - Use
tasks_fromfor multi-operation roles (install/configure/upgrade) instead of cloning a role per operation. - Document the role in
README.mdand validate inputs withmeta/argument_specs.ymlso bad parameters fail fast with a clear error.
Security notes
- Vet third-party Galaxy content before you run it. A role or collection executes with whatever privileges your play has (often
become: true). Read the tasks, pin a specific version (never float onmain), and prefer well-maintained, widely-used roles/collections over unknown ones. - Pin by version, not by
latest/main. An unpinned dependency means a future upstream change runs on your hosts without review — both a stability and a supply-chain risk. Version pins inrequirements.ymlare a security control. - Never bake secrets into
defaults/orvars/. Role variables live in plain-text YAML in the repo. Keep secrets in Ansible Vault (or an external secret manager) and pass them in, so a published or shared role never carries credentials. - Mind
allow_duplicatesand dependency side effects. A dependency that creates users, opens firewall ports, or writes config runs automatically before the role — review the full dependency tree (ansible-galaxy role list, read eachmeta/main.yml) so nothing privileged runs unseen. - Use a private Galaxy/Automation Hub for internal collections and authenticate with a token in
ansible.cfg’s[galaxy_server.*]section — keep that token out of the repo and out of CI logs. - Keep custom modules in
library//collections reviewed. Role-local modules run arbitrary code on targets; treat them with the same scrutiny as any other code you execute as root.
Interview & exam questions
- What are the standard directories in a role and which
main.ymlfiles are auto-loaded?tasks/,handlers/,templates/,files/,vars/,defaults/,meta/(plus optionallibrary/,module_utils/,<type>_plugins/). The auto-loadedmain.ymlfiles are intasks/,handlers/,vars/,defaults/, andmeta/—tasks/main.ymlis the default entry point.templates/andfiles/enable bare-name lookups. roles:keyword vsimport_rolevsinclude_role— what’s the difference?roles:andimport_roleare static (parsed before the play runs);roles:runs roles beforetasks:,import_roleruns at its position.include_roleis dynamic (resolved at run time): it can be looped and gated as a whole withwhen, but its inner tasks aren’t visible to--list-tasks/--start-at-taskand inner tags aren’t matched by--tags.- Which can you
loop, and which exposes inner tasks to--start-at-task? You canlooponlyinclude_role(dynamic).import_role/roles:(static) expose inner tasks to--list-tasks,--start-at-task, and--tags. - Where do you put a variable a user should be able to override, and one they shouldn’t? Overridable →
defaults/main.yml(lowest precedence). Protected →vars/main.yml(high precedence, beaten only by block/task vars and-e). - A role appears as a dependency of three other roles. How many times does it run, and how do you change that? Once — Ansible deduplicates a dependency run with the same parameters. It runs again if invoked with different parameters, or every time if the role sets
allow_duplicates: trueinmeta/main.yml. - What is
tasks_fromfor? It runs a non-default entry point —tasks/<name>.ymlinstead oftasks/main.yml— so one role can expose multiple operations (install/configure/upgrade).handlers_from,vars_from, anddefaults_fromload matching alternate files. - What is an FQCN and why use it? Fully-Qualified Collection Name —
namespace.collection.name(e.g.ansible.builtin.copy,community.general.ufw). It removes ambiguity when two collections define the same module/role name, is self-documenting, and is enforced byansible-lint’s production profile. - Standalone role vs collection — how do they differ and where is each installed? A standalone role is one role (
ns.role) installed underroles_path. A collection (ns.collection) bundles many roles plus modules, plugins, playbooks, is semantically versioned, and installs undercollections_pathasansible_collections/<ns>/<coll>/. - What does the
collections:keyword do, and should you rely on it? It sets a search list of collections for unqualified names in a play/role, soufwcan resolve tocommunity.general.ufw. It’s a backward-compatibility convenience; the modern recommendation is to use explicit FQCNs instead.ansible.builtinis always searched as a fallback. - Show the two sections of a
requirements.ymland how to install both. Top-levelroles:andcollections:. Roles can come from Galaxy (name/version), git (src+scm: git+version), or a tarball URL; collections from Galaxy (name/version), git/url/file (type:). Install both at once withansible-galaxy install -r requirements.yml(or the per-type subcommands). - How do you pin a collection to get bug fixes but never a breaking change? Use a version range with a major ceiling, e.g.
version: ">=8.0.0,<9.0.0", so installs pick up 8.x patches/minors but never jump to 9.0. - Where does
-e(extra-vars) sit relative to a role’svars/anddefaults/, and why care?-eis the highest precedence — it beats bothvars/anddefaults/. That makes it perfect for one-off CI overrides but dangerous to rely on for normal config, because it silently overrides everything else.
Quick check
- Which two role directories’
main.ymlfiles sit at opposite ends of variable precedence, and which is overridable? - You need to run a role once per item in a list — which invocation method must you use?
- By default, how many times does a role listed as a dependency of two different roles run in one play?
- What is the FQCN for the core
copymodule, and which collection ships it? - Name the two top-level keys in a
requirements.ymland the single command that installs both.
Answers
defaults/main.yml(lowest precedence — overridable) andvars/main.yml(high precedence — not casually overridable). Tunables go indefaults/.ansible.builtin.include_rolewith aloop:— only the dynamic include can be looped as a unit (import_role/roles:cannot).- Once — Ansible deduplicates a dependency with identical parameters unless it sets
allow_duplicates: trueor is invoked with different parameters. ansible.builtin.copy— it ships in theansible.builtincollection (the core set bundled withansible-core).roles:andcollections:; install both withansible-galaxy install -r requirements.yml.
Exercise
Build a small, production-shaped role layout entirely on localhost (cost ₹0). (a) ansible-galaxy role init a role called apache with a defaults/main.yml exposing apache_port (default 80) and apache_docroot, a vars/main.yml holding an OS-specific package name, a templates/index.html.j2 using ansible_managed, and a tasks/main.yml that renders the page and notifies a handler. (b) Add a second role baseline that just debugs a message, and make apache depend on it via meta/main.yml; then list apache twice in a play and prove baseline runs only once, then flip allow_duplicates: true and prove it runs twice. © Add a tasks/uninstall.yml to apache and invoke it with include_role: { tasks_from: uninstall }. (d) Write a requirements.yml that pulls one Galaxy collection pinned to a >=x,<x+1 range and one standalone role from a git URL pinned to a tag, and install both project-locally with one command. (e) Run the play once with the default apache_port, then once with -e apache_port=8080, and confirm the rendered page changes — explaining in one sentence why -e won over the defaults/ value. (f) Clean up the project directory. In two sentences, explain why apache_port belongs in defaults/ (not vars/) and why you used include_role (not import_role) for the uninstall-by-tasks_from step only if you needed to loop or conditionally run it.
Certification mapping
- RHCE (EX294) — “Create and use roles”: this is a direct, heavily weighted objective. Expect to create a role with the standard directory structure, place variables correctly in
defaults/vsvars/, use templates/files by bare name, declare dependencies inmeta/main.yml, and use roles in a playbook (theroles:keyword andimport_role/include_role). Practisingansible-galaxy role initand a clean defaults-vs-vars split is essential. - RHCE (EX294) — “Download roles from Ansible Galaxy / content collections and use them”: installing roles and collections (
ansible-galaxy install,ansible-galaxy collection install), writing arequirements.yml, and using modules by FQCN map directly to exam tasks; knowcollections_path/roles_pathand project-local installs. - RHCE (EX294) — “Use provided documentation to look up specific information”:
ansible-galaxy collection list,ansible-doc -l, and locating a collection’s roles/modules support this objective when you must discover what an installed collection provides. - Beyond RHCE: the role/collection model underpins the EX374 (Automation Platform/AAP) curriculum — Automation Hub, signed collections, and execution environments all build on exactly these concepts.
Glossary
- Role — a self-contained, reusable unit of automation in a fixed directory layout (
tasks/,handlers/,templates/,files/,vars/,defaults/,meta/, …). tasks/main.yml— a role’s default entry point, run automatically when the role is invoked.defaults/main.yml— the role’s lowest-precedence variables; its overridable public API.vars/main.yml— the role’s high-precedence variables; internal constants not meant for casual override.meta/main.yml— role metadata: dependencies,galaxy_info, supportedplatforms, andallow_duplicates.roles:keyword — the play-level list of roles, run statically beforetasks:.import_role— static role inclusion at a specific task position (visible to--list-tasks/--tags, not loopable).include_role— dynamic role inclusion at run time (loopable, conditional as a unit, opaque to pre-run listing).tasks_from— run an alternate entry point (tasks/<name>.yml) instead ofmain.yml; pairs withhandlers_from/vars_from/defaults_from.- Role parameter — a variable passed into a role at call time (via
vars:); higher precedence than the role’s defaults and inventory vars. allow_duplicates—meta/main.ymlflag (defaultfalse) allowing a role to run more than once with the same parameters in one play.- Collection — a distributable bundle
namespace.collectionof roles, modules, plugins, and playbooks; semantically versioned. - FQCN — Fully-Qualified Collection Name,
namespace.collection.name(e.g.ansible.builtin.copy); the unambiguous way to address modules/roles/plugins. ansible.builtin— the core collection bundled withansible-core(copy, file, template, service, package, debug, setup, …).collections:keyword — a search list of collections for unqualified names in a play/role; a backward-compat convenience (prefer FQCNs).- Ansible Galaxy — the public hub (galaxy.ansible.com) and the
ansible-galaxyCLI for searching, installing, and scaffolding roles and collections. collections_path/roles_path— config settings (inansible.cfg) for where Ansible installs and searches collections (asansible_collections/<ns>/<coll>/) and roles.requirements.yml— the dependency manifest listingroles:andcollections:with sources (Galaxy/git/url/file) and versions, installed viaansible-galaxy install -r.
Next steps
You can now structure, parameterise, depend on, distribute, and install roles and collections end to end — the directory contract, the three invocation methods (static vs dynamic), tasks_from, dependencies and allow_duplicates, the defaults-vs-vars precedence rules, Ansible Galaxy, the FQCN and collections: keyword, collections_path, and a reproducible requirements.yml. The natural next move is secrets: roles need passwords and keys, and you must never commit them — read Ansible Vault, In Depth to encrypt variables and files, manage vault IDs, and integrate Vault with group_vars/host_vars and CI. To then prove your roles are genuinely correct and idempotent across distros, study engineering idempotent Ansible collections with Molecule testing, which takes the collection you can now build and wraps it in a create → converge → idempotence → verify test matrix in CI.