Ansible Lesson 11 of 42

Ansible Roles & Collections, In Depth: Structure, Dependencies, Galaxy & requirements.yml

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 bundlenamespace.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:

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.j2bare 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.htmlbare 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:

  1. main.yml is the entry point in every directory that holds YAML. tasks/main.yml, handlers/main.yml, vars/main.yml, defaults/main.yml, and meta/main.yml are loaded automatically when the role runs — you never name them explicitly. A role with only tasks/main.yml is a perfectly valid role; every other directory is optional.
  2. templates/ and files/ enable bare-name references. Inside a role, ansible.builtin.template: src: app.conf.j2 resolves to roles/<role>/templates/app.conf.j2 automatically, and copy: src: logo.png resolves to roles/<role>/files/logo.png. This relative search path is one of the biggest ergonomic wins of roles — no long paths.
  3. 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 in defaults/; values the role author wants to protect go in vars/.

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:

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:

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:

# 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:

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:

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 roles and collections: the role directory structure, the roles:/import_role/include_role invocation paths, meta dependencies, defaults-vs-vars precedence, Galaxy, FQCN resolution, and requirements.yml installing roles and collections

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

Security notes

Interview & exam questions

  1. What are the standard directories in a role and which main.yml files are auto-loaded? tasks/, handlers/, templates/, files/, vars/, defaults/, meta/ (plus optional library/, module_utils/, <type>_plugins/). The auto-loaded main.yml files are in tasks/, handlers/, vars/, defaults/, and meta/tasks/main.yml is the default entry point. templates/ and files/ enable bare-name lookups.
  2. roles: keyword vs import_role vs include_role — what’s the difference? roles: and import_role are static (parsed before the play runs); roles: runs roles before tasks:, import_role runs at its position. include_role is dynamic (resolved at run time): it can be looped and gated as a whole with when, but its inner tasks aren’t visible to --list-tasks/--start-at-task and inner tags aren’t matched by --tags.
  3. Which can you loop, and which exposes inner tasks to --start-at-task? You can loop only include_role (dynamic). import_role/roles: (static) expose inner tasks to --list-tasks, --start-at-task, and --tags.
  4. 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).
  5. 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: true in meta/main.yml.
  6. What is tasks_from for? It runs a non-default entry pointtasks/<name>.yml instead of tasks/main.yml — so one role can expose multiple operations (install/configure/upgrade). handlers_from, vars_from, and defaults_from load matching alternate files.
  7. What is an FQCN and why use it? Fully-Qualified Collection Namenamespace.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 by ansible-lint’s production profile.
  8. Standalone role vs collection — how do they differ and where is each installed? A standalone role is one role (ns.role) installed under roles_path. A collection (ns.collection) bundles many roles plus modules, plugins, playbooks, is semantically versioned, and installs under collections_path as ansible_collections/<ns>/<coll>/.
  9. 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, so ufw can resolve to community.general.ufw. It’s a backward-compatibility convenience; the modern recommendation is to use explicit FQCNs instead. ansible.builtin is always searched as a fallback.
  10. Show the two sections of a requirements.yml and how to install both. Top-level roles: and collections:. 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 with ansible-galaxy install -r requirements.yml (or the per-type subcommands).
  11. 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.
  12. Where does -e (extra-vars) sit relative to a role’s vars/ and defaults/, and why care? -e is the highest precedence — it beats both vars/ and defaults/. 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

  1. Which two role directories’ main.yml files sit at opposite ends of variable precedence, and which is overridable?
  2. You need to run a role once per item in a list — which invocation method must you use?
  3. By default, how many times does a role listed as a dependency of two different roles run in one play?
  4. What is the FQCN for the core copy module, and which collection ships it?
  5. Name the two top-level keys in a requirements.yml and the single command that installs both.

Answers

  1. defaults/main.yml (lowest precedence — overridable) and vars/main.yml (high precedence — not casually overridable). Tunables go in defaults/.
  2. ansible.builtin.include_role with a loop: — only the dynamic include can be looped as a unit (import_role/roles: cannot).
  3. Once — Ansible deduplicates a dependency with identical parameters unless it sets allow_duplicates: true or is invoked with different parameters.
  4. ansible.builtin.copy — it ships in the ansible.builtin collection (the core set bundled with ansible-core).
  5. roles: and collections:; install both with ansible-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

Glossary

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.

ansiblerolescollectionsgalaxyrequirements-ymlRHCE
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments