Module 06 — Collections & Content Structure

Updated 20 August 2026

Module 06 · Collections & Content Structure

Packaging, versioning and distributing Ansible content — and laying out a repository a team can actually work in. This is the module that turns "my playbooks" into "our platform".

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–05. You met namespaces, collections and Galaxy in Module 01 Part A4 — this module goes from consuming them to building them.


Part A · Anatomy of a collection

A1 · What a collection actually contains

The analogy. Think of an app in a phone's app store. It has a publisher and a name, and the store always shows you both — because there are four apps called "Notes" and only one of them is the one you meant. It has a version number, so "it worked on 3.1" is a precise statement rather than a feeling. It ships everything it needs in one download. And it can say "requires iOS 15 or later".

A collection is that app: amazon is the publisher, aws is the name, and Galaxy is the store. Each of those four everyday properties maps onto a real file you are about to meet — the publisher and name live in galaxy.yml, and "requires iOS 15 or later" is requires_ansible in meta/runtime.yml.

A role packages one concern. A collection packages everything a domain needs — including the things a role structurally cannot hold: modules, plugins and playbooks.

plain text
mycompany/platform/
  galaxy.yml                 <- the manifest: namespace, name, version, dependencies
  README.md                  <- what this collection is for
  LICENSE
  meta/
    runtime.yml              <- requires_ansible, action_groups, plugin redirects
  plugins/
    modules/                 -> mycompany.platform.create_tenant
    module_utils/            <- shared Python for those modules
    action/                  <- action plugins (run on the control node)
    filter/                  -> mycompany.platform.to_subnet
    lookup/                  -> lookup('mycompany.platform.vault_secret', ...)
    inventory/               -> plugin: mycompany.platform.cmdb
    callback/                <- custom output formatting
    connection/              <- custom transports
    test/                    <- custom Jinja2 tests
  roles/
    webserver/               -> mycompany.platform.webserver
    monitoring/
  playbooks/
    site.yml                 -> mycompany.platform.site
    files/  templates/  tasks/
  docs/
  changelogs/
    changelog.yaml
  tests/
    sanity/  unit/  integration/
Every directory maps directly to an FQCN. A module in plugins/modules/create_tenant.py becomes mycompany.platform.create_tenant. A role in roles/webserver/ becomes mycompany.platform.webserver. A filter in plugins/filter/ becomes mycompany.platform.to_subnet.

That is the whole naming system from Module 01 Part A4, seen from the producing side rather than the consuming side.

Only galaxy.yml is mandatory. A collection containing nothing but roles/ is perfectly valid, and that is exactly how most internal collections start — as a way to version and distribute a handful of roles as one unit. Add plugins later when you actually need them.

A2 · galaxy.yml — the manifest

The analogy. Think about what an app's version number actually promises you. A small last-number update is a bug fix — you install it without a thought. The middle number going up means new features arrived, but everything you already do still works. A major version change is the one where the whole interface gets redesigned and the button you pressed every morning has moved.

That is semantic versioning, and it is a promise rather than a number. Shipping a redesign as a minor update is how an app loses its users' trust, and it is exactly how a collection loses its consumers' — because their pinned >=2.1,<3.0 was them believing you.

yaml
---
namespace: mycompany
name: platform
version: 1.4.2                    # MUST be semantic versioning
readme: README.md
authors:
  - Zaeem Mazhar <[email protected]>
description: Internal platform automation - web, monitoring and base configuration
license_file: LICENSE
tags:
  - infrastructure
  - linux

dependencies:
  ansible.posix: ">=1.5.0,<2.0.0"
  community.general: ">=8.0.0"

repository: https://git.internal/ansible/platform
documentation: https://docs.internal/ansible/platform
issues: https://git.internal/ansible/platform/-/issues

build_ignore:                     # excluded from the built artifact
  - .git
  - .gitlab-ci.yml
  - molecule
  - "*.tar.gz"
Semantic versioning is enforced, not suggested. Galaxy rejects a version that is not MAJOR.MINOR.PATCH. That matters because consumers pin ranges like ">=1.4.0,<2.0.0" — which only means anything if you honour the contract:

MAJOR — a breaking change: a renamed variable, a removed role, changed default behaviour.

MINOR — new functionality, backwards compatible.

PATCH — a bug fix, no interface change.

Bumping a MINOR for something that actually breaks consumers is how you lose a team's trust in your collection.

A3 · meta/runtime.yml

yaml
---
requires_ansible: ">=2.15.0"        # fails clearly on an older control node

action_groups:                      # lets consumers apply module_defaults in bulk
  platform:
    - create_tenant
    - delete_tenant

plugin_routing:                     # keeps old names working after a rename
  modules:
    old_module_name:
      redirect: mycompany.platform.new_module_name
    removed_module:
      tombstone:
        removal_version: "2.0.0"
        warning_text: "Use new_module_name instead"
plugin_routing is how you rename something without breaking every consumer. A redirect keeps the old FQCN working silently; a tombstone fails with a message telling people what to use instead.

This is the mechanism the Ansible project itself used during the 2.10 split — which is why bare yum: still resolves today. Knowing that connection is a genuinely good answer to "how do collections handle deprecation?"

🧪 Exercise A3.1 — Scaffold a collection and read what you get
bash
ansible-galaxy collection init mycompany.platform
find mycompany -type f -o -type d | sort
cat mycompany/platform/galaxy.yml
cat mycompany/platform/meta/runtime.yml
Expected result — click to reveal
plain text
- Collection mycompany.platform was created successfully
plain text
mycompany
mycompany/platform
mycompany/platform/README.md
mycompany/platform/docs
mycompany/platform/galaxy.yml
mycompany/platform/meta
mycompany/platform/meta/runtime.yml
mycompany/platform/plugins
mycompany/platform/plugins/README.md
mycompany/platform/roles
yaml
namespace: mycompany
name: platform
version: 1.0.0
readme: README.md
authors:
  - your name <[email protected]>
description: your collection description
dependencies: {}
license_file: ''
tags: []
repository: http://example.com/repository

Note how little you get — and note the nesting. The scaffolder creates mycompany/platform/, a two-level directory, because that is exactly how collections are stored on disk: <namespace>/<name>/. Every installed collection lives under ansible_collections/<namespace>/<name>/.

Three things to fix immediately:

  1. version: 1.0.0 — the scaffolder's optimism. Start at 0.1.0 while the interface is still moving, so you are free to make breaking changes without burning major versions.
  2. license_file: '' — empty and invalid. Galaxy rejects a build without a license. Set license_file: LICENSE and add the file, or use the license: key with an SPDX identifier.
  3. No plugins/modules/ yet — create the subdirectories you actually need. An empty plugins/ tree is noise.

Also open meta/runtime.yml — it contains only requires_ansible: '>=2.15.0'. Set that honestly to the lowest version you have actually tested against, because consumers get a clear failure instead of a mysterious one.

🎯 Interview questions — Collection anatomy

Q. What can a collection contain that a role cannot?

Modules, plugins of every kind (filter, lookup, inventory, callback, connection, action, test), module_utils, and playbooks — plus roles.

A role is limited to tasks, handlers, templates, files, defaults and vars. A collection is the superset, and it is versioned with declared dependencies on other collections.

Q. What is in galaxy.yml?

The manifest: namespace, name, version (semantic versioning, enforced), readme, authors, description, license or license_file, tags, dependencies on other collections with version ranges, repository and documentation URLs, and build_ignore for files to exclude from the artifact.

It is the collection's identity and its dependency contract.

Q. What is meta/runtime.yml for?

Three things. requires_ansible declares the minimum ansible-core version, so an old control node fails with a clear message. action_groups lets consumers apply module_defaults to a whole group of modules at once. And plugin_routing handles renames and removals — a redirect keeps an old FQCN working silently, a tombstone fails with guidance.

plugin_routing is the same mechanism the Ansible project used during the 2.10 split, which is why bare yum: still resolves today.

Q. Why does semantic versioning matter for a collection?

Because consumers pin ranges such as ">=1.4.0,<2.0.0", and that pin only protects them if you honour the contract: MAJOR for breaking changes, MINOR for backwards-compatible additions, PATCH for fixes.

Galaxy enforces the format but cannot enforce the meaning — shipping a breaking change as a MINOR bump silently breaks every consumer who pinned correctly, which is the fastest way to lose a team's trust in your collection.


Part B · Building and publishing

B1 · The lifecycle

The analogy. Think of posting a parcel you packed yourself. You fill the box, seal it, and then — if you have any sense — you open one test parcel yourself before sending two hundred of them out. That is the moment you discover the instruction leaflet is still lying on your desk, because you had been working from what was in front of you rather than from what actually went into the box.

ansible-galaxy collection build seals the box; installing that tarball into a clean folder is opening a test one. Testing against your source directory instead is testing the desk, not the parcel — and the things that fall out of a real build are almost always files you forgot to include in build_ignore's opposite.

Diagram source
flowchart LR
    A["ansible-galaxy<br>collection init"] --> B["Add roles, plugins,<br>modules, playbooks"]
    B --> C["Bump version<br>in galaxy.yml"]
    C --> D["ansible-galaxy<br>collection build"]
    D --> E["mycompany-platform-1.4.2.tar.gz"]
    E --> F{"Where does it go?"}
    F -->|"Public"| G["ansible-galaxy<br>collection publish<br>-> galaxy.ansible.com"]
    F -->|"Private"| H["Private Automation Hub<br>or Artifactory<br>or a git tag"]
    F -->|"Local test"| I["ansible-galaxy collection install<br>./mycompany-platform-1.4.2.tar.gz<br>-p ./collections"]
    style E fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
    style I fill:#FEF3C7,stroke:#D97706
bash
ansible-galaxy collection init mycompany.platform
# ... add content, bump the version ...
ansible-galaxy collection build                              # -> .tar.gz in cwd
ansible-galaxy collection build --output-path ./dist --force
ansible-galaxy collection install ./dist/mycompany-platform-1.4.2.tar.gz -p ./collections
ansible-galaxy collection publish ./dist/mycompany-platform-1.4.2.tar.gz --api-key=$GALAXY_KEY
The local install step is the one people skip and should not. Building and installing into ./collections lets you test the collection exactly as a consumer will experience it — with real FQCNs, real dependency resolution, and the build_ignore exclusions applied. Testing against your source tree instead hides an entire class of packaging bugs, such as a file you forgot to include.

B2 · Using a git repository as the source

You do not need Galaxy or a Hub to distribute internally — a git tag is enough:

yaml
# requirements.yml
---
collections:
  - name: https://git.internal/ansible/platform.git
    type: git
    version: v1.4.2                 # a tag, branch or commit SHA
bash
ansible-galaxy collection install -r requirements.yml -p ./collections
For a small team this is often the right answer. No registry to run, versioning by git tag, access control already handled by your git server. The trade-off is no dependency resolution across collections and no artifact immutability — someone can move a tag. Once that matters, move to a Private Automation Hub or an Artifactory generic repository.

B3 · Where collections are installed and found

The analogy. Think of packing for a trip. You can rely on the hotel having a hairdryer, or you can put one in your suitcase. Relying on the hotel works beautifully right up until the one hotel that does not have one — usually the trip where it matters.

Installing collections into ~/.ansible/ is relying on the hotel: it depends on whatever happens to be on that particular machine. collections_path = ./collections is packing them in the suitcase — the project carries what it needs, and a CI runner that has never seen your laptop ends up with exactly the same versions you tested against.

Diagram source
flowchart TD
    A["FQCN: mycompany.platform.webserver"] --> B{"ANSIBLE_COLLECTIONS_PATH<br>or collections_path<br>in ansible.cfg?"}
    B -->|"Found"| Z["Use it"]
    B -->|"No"| C{"./collections/<br>next to the playbook?"}
    C -->|"Found"| Z
    C -->|"No"| D{"~/.ansible/collections/"}
    D -->|"Found"| Z
    D -->|"No"| E{"/usr/share/ansible/collections/"}
    E -->|"Found"| Z
    E -->|"No"| F["ERROR: couldn't resolve<br>module/action"]
    style Z fill:#D1FAE5,stroke:#059669,stroke-width:2px
    style F fill:#FEE2E2,stroke:#DC2626
plain text
# ansible.cfg — the project-local convention
[defaults]
collections_path = ./collections
plain text
project/
  ansible.cfg
  requirements.yml
  collections/                          <- gitignored, rebuilt by CI
    ansible_collections/
      mycompany/platform/
      community/general/
      ansible/posix/
Install into the project, not the user's home. collections_path = ./collections plus a gitignored collections/ directory means the project is self-contained: CI runs ansible-galaxy install -r requirements.yml and gets exactly the pinned versions, with no dependence on whatever happens to be in a developer's ~/.ansible/.

The alternative — relying on the home directory — is how "works on my machine" happens in Ansible.

B4 · The collections: keyword

yaml
- hosts: web
  collections:
    - mycompany.platform          # lets you write short names from this collection
  tasks:
    - name: Short name resolves via the collections keyword
      create_tenant:
        name: acme

    - name: FQCN always works, regardless
      mycompany.platform.create_tenant:
        name: acme
Prefer the FQCN and skip the keyword. The collections: keyword adds a search path for unqualified names, which reintroduces exactly the ambiguity FQCNs were created to remove — two collections in the list defining create_tenant and the winner depends on list order.

ansible-lint flags short names. Use the keyword only when maintaining older code that already relies on it.

🧪 Exercise B4.1 — Build, install and consume your own collection
bash
# 1. Move a role you already have into the collection
mkdir -p mycompany/platform/roles
cp -r roles/webserver mycompany/platform/roles/

# 2. Fix the manifest the scaffolder got wrong
cd mycompany/platform
sed -i "s/^version: .*/version: 0.1.0/" galaxy.yml
sed -i "s|^license_file: .*|license_file: LICENSE|" galaxy.yml
echo "MIT" > LICENSE

# 3. Build and install it locally
ansible-galaxy collection build --output-path /tmp/dist --force
cd -
ansible-galaxy collection install /tmp/dist/mycompany-platform-0.1.0.tar.gz -p ./collections --force

# 4. Confirm Ansible can see it
ansible-galaxy collection list | grep mycompany
ansible-doc -t role mycompany.platform.webserver
yaml
# use-collection.yml
---
- hosts: web
  become: true
  roles:
    - mycompany.platform.webserver        # the FQCN, not a bare role name
Expected result — click to reveal
plain text
Created collection for mycompany.platform at /tmp/dist/mycompany-platform-0.1.0.tar.gz
plain text
Installing 'mycompany.platform:0.1.0' to '/home/zaeem/lab/collections/ansible_collections/mycompany/platform'
mycompany.platform:0.1.0 was installed successfully
plain text
# /home/zaeem/lab/collections/ansible_collections
Collection          Version
------------------- -------
mycompany.platform  0.1.0

Look at the install path. The collection landed at collections/ansible_collections/mycompany/platform/ — the ansible_collections/<namespace>/<name>/ structure is fixed and not negotiable. That is why collections_path points at the directory containing ansible_collections, not at ansible_collections itself, which is a common off-by-one when configuring it by hand.

Now the role is addressed by FQCNmycompany.platform.webserver rather than webserver. It no longer needs to sit in ./roles/, it is versioned, and ansible-galaxy collection list reports exactly which version is in use.

Try removing --force and re-running the install. Ansible refuses to overwrite an existing collection of the same version — which is correct behaviour, and the reason you bump the version during development rather than rebuilding the same one repeatedly.

🎯 Interview questions — Building collections

Q. Walk me through building and distributing a collection.

ansible-galaxy collection init namespace.name to scaffold, add roles and plugins, set the version in galaxy.yml, then ansible-galaxy collection build to produce a tarball.

Distribution: publish to public Galaxy, upload to a Private Automation Hub or Artifactory for internal use, or simply install from a git tag via requirements.yml, which is often enough for a small team.

The step worth mentioning: install the built tarball locally into ./collections and test against that, not against your source tree — it is the only way to catch packaging mistakes such as a file excluded by build_ignore.

Q. Where should collections be installed in a project?

Into the project — collections_path = ./collections in ansible.cfg, with the directory gitignored and rebuilt by CI from a pinned requirements.yml.

That makes the project self-contained and reproducible, rather than depending on whatever versions happen to be in a particular developer's ~/.ansible/collections.

The physical layout under it is fixed: collections/ansible_collections/<namespace>/<name>/.

Q. What is the collections: keyword and should you use it?

It adds collections to the search path for unqualified module and role names in a play.

Generally you should not use it — it reintroduces the ambiguity FQCNs exist to remove, since two listed collections defining the same name resolve by list order. ansible-lint flags short names. Use FQCNs, and reach for the keyword only when maintaining older code that already depends on it.


Part C · Structuring a real repository

C1 · The canonical layout

The analogy. Think of separate drawers for winter and summer clothes. Winter things in one, summer things in another — you never have to think about it, because you open a drawer and everything inside it belongs together. Now picture one shared drawer instead, with a note on top saying which season it currently is. Somebody moves a jumper, nobody re-reads the note, and you end up dressed for the wrong weather.

group_vars/ inside each inventory folder is separate drawers; group_vars/ at the repository root is the shared drawer with a note on it. And the wrong jumper, in this case, is a variable you edited for staging quietly applying to production. Separate drawers is one of the few layout decisions that is genuinely hard to regret.

plain text
ansible-platform/
  ansible.cfg                      <- project config, checked in
  requirements.yml                 <- pinned roles AND collections
  .gitignore
  .ansible-lint
  .yamllint

  inventories/
    production/
      hosts.yml                    <- or aws_ec2.yml for dynamic
      group_vars/
        all.yml
        web.yml
        db.yml
        vault.yml                  <- encrypted
      host_vars/
        web01.yml
    staging/
      hosts.yml
      group_vars/
        all.yml
        web.yml

  playbooks/
    site.yml                       <- the entry point
    webservers.yml
    databases.yml
    maintenance/
      patch.yml
      rotate-certs.yml

  roles/                           <- roles YOU write
    common/
    webserver/

  collections/                     <- gitignored, built by CI
  galaxy_roles/                    <- gitignored, built by CI

  files/                           <- shared static files
  templates/                       <- shared templates
  library/                         <- one-off custom modules not worth a collection

  tests/
  molecule/
The single most important structural rule: group_vars/ belongs inside each inventory directory, not at the repository root.

Put it at the root and every environment shares the same variables, which means one careless edit applies production values to staging. Put it under inventories/production/ and inventories/staging/ and the environments are genuinely separated by the filesystem.

bash
ansible-playbook -i inventories/staging playbooks/site.yml
ansible-playbook -i inventories/production playbooks/site.yml

Pointing -i at the directory loads the inventory file and its adjacent group_vars and host_vars automatically.

C2 · ansible.cfg for a real project

plain text
[defaults]
inventory          = ./inventories/production
roles_path         = ./roles:./galaxy_roles
collections_path   = ./collections
host_key_checking  = True
forks              = 25
stdout_callback    = yaml
callbacks_enabled  = profile_tasks, timer
interpreter_python = auto_silent
retry_files_enabled = False
vault_password_file = ~/.ansible-vault-pass      # NOT checked in

[privilege_escalation]
become        = True
become_method = sudo
become_user   = root

[ssh_connection]
pipelining = True
ssh_args   = -o ControlMaster=auto -o ControlPersist=60s
Defaulting inventory to production is a deliberate, debatable choice. It means forgetting -i targets production. Many teams instead point the default at staging, so a forgotten flag is harmless — and some set no default at all so the command always fails until you are explicit.

There is no universally right answer, but there is a wrong one: defaulting to production without the team knowing.

C3 · .gitignore

plain text
# Installed content - rebuilt from requirements.yml
collections/
galaxy_roles/

# Secrets - never
*.vault-pass
.vault_pass*
*.pem
*.key

# Local overrides
ansible.cfg.local
inventories/local/

# Artifacts
*.retry
*.tar.gz
__pycache__/
.molecule/
Commit requirements.yml, ignore what it installs. The pinned file is the source of truth and belongs in review; the downloaded content is a build artifact. Committing collections/ bloats the repository, creates enormous meaningless diffs, and hides the fact that a dependency changed.
🧪 Exercise C3.1 — Prove that per-inventory group_vars are isolated
bash
mkdir -p inventories/{production,staging}/group_vars

printf -- "---\nweb:\n  hosts:\n    web01:\n" > inventories/production/hosts.yml
printf -- "---\nweb:\n  hosts:\n    stg01:\n" > inventories/staging/hosts.yml

printf -- "---\nenv_name: PRODUCTION\napp_replicas: 6\n" > inventories/production/group_vars/all.yml
printf -- "---\nenv_name: STAGING\napp_replicas: 1\n"    > inventories/staging/group_vars/all.yml

cat > check.yml <<'EOF'
---
- hosts: all
  gather_facts: false
  tasks:
    - ansible.builtin.debug:
        msg: "{{ inventory_hostname }} -> {{ env_name }} with {{ app_replicas }} replicas"
EOF

ansible-playbook -i inventories/staging check.yml
ansible-playbook -i inventories/production check.yml
ansible-inventory -i inventories/staging --host stg01
Expected result — click to reveal
plain text
TASK [ansible.builtin.debug] ***************************************
ok: [stg01] => {
    "msg": "stg01 -> STAGING with 1 replicas"
}
plain text
TASK [ansible.builtin.debug] ***************************************
ok: [web01] => {
    "msg": "web01 -> PRODUCTION with 6 replicas"
}
json
{
    "app_replicas": 1,
    "env_name": "STAGING"
}

Notice what you did not have to do. No -e env=staging, no conditional in the playbook, no when: env == 'prod'. The same playbook produced different values purely because -i pointed at a different directory, and Ansible loaded the group_vars sitting beside that inventory automatically.

That automatic adjacency is the whole mechanism, and it is why the directory layout is a correctness concern rather than a tidiness one. Move group_vars/ to the repository root and both commands print the same values — the environments silently merge, and a staging change lands in production.

The safety property worth stating in an interview: with this layout, running against the wrong environment requires typing the wrong path, which is visible in shell history and in CI job definitions. With a shared root group_vars, it requires forgetting a flag — invisible and far easier to do.

🎯 Interview questions — Repository structure

Q. How do you structure an Ansible repository for multiple environments?

A directory per environment under inventories/production/, staging/ — each containing its own hosts.yml and its own group_vars/ and host_vars/. Then -i inventories/production loads the inventory and its adjacent variables together.

The critical rule: group_vars/ goes inside each inventory directory, never at the repository root. At the root, all environments share variables and a staging edit reaches production.

Roles, playbooks and collections stay shared; only inventory and variables are per-environment.

Q. What do you commit and what do you gitignore?

Commit: ansible.cfg, requirements.yml with pinned versions, inventories, group_vars (with secrets Vault-encrypted), playbooks, your own roles, lint config.

Ignore: collections/ and galaxy_roles/ — build artifacts rebuilt from requirements.yml — plus vault password files, private keys, *.retry, and any local override config.

The principle: commit the declaration, ignore what it installs. Committing downloaded content bloats the repo and hides dependency changes in enormous diffs.

Q. Should ansible.cfg default the inventory to production?

It is a real trade-off and worth showing you have thought about it. Defaulting to production means a forgotten -i targets production. Defaulting to staging makes a forgotten flag harmless. Setting no default forces every command to be explicit and fails otherwise.

Many teams choose staging or no default for exactly that reason. The genuinely wrong answer is defaulting to production without the team being aware of it.


Part D · Reproducible runtimes

D1 · The problem requirements.yml does not solve

The analogy. Think of taking lunch to work. You can bring the ingredients and rely on the office kitchen having a microwave, a plate and a fork — which works fine until the morning the microwave is broken. Or you bring the whole meal in a sealed box, ready to eat, needing nothing at all from the building.

requirements.yml brings the ingredients — your collections. What it cannot pin is the microwave: the Ansible version itself, the Python interpreter, and libraries like boto3 that your modules quietly depend on. An Execution Environment is the sealed lunchbox, and that is why it is the only complete answer to "but it works on my machine".

Pinning collections makes content reproducible. It does not pin ansible-core itself, the Python version, or any Python library a module needs — boto3 for AWS, netaddr for ipaddr, kubernetes for k8s.

The classic failure: the playbook works on your laptop and fails in CI with Failed to import the required Python library (boto3). Nothing is wrong with your playbook. Your laptop has a library the runner does not.

requirements.yml cannot express that dependency, because it is not Ansible content — it is a Python dependency of the control node.

D2 · Execution Environments

An Execution Environment is a container image containing ansible-core, your collections, and their Python dependencies — one immutable artifact that runs identically on a laptop, in CI, and in AWX.

yaml
# execution-environment.yml
---
version: 3

images:
  base_image:
    name: quay.io/ansible/ansible-runner:latest

dependencies:
  ansible_core:
    package_pip: ansible-core==2.16.3      # the engine, pinned
  ansible_runner:
    package_pip: ansible-runner
  galaxy: requirements.yml                  # your collections, pinned
  python: requirements.txt                  # boto3, netaddr, kubernetes...
  system: bindep.txt                        # OS packages, e.g. openssh-clients
bash
pip install ansible-builder
ansible-builder build --tag mycompany/ansible-ee:1.4.2 --file execution-environment.yml
ansible-navigator run playbooks/site.yml --eei mycompany/ansible-ee:1.4.2
podman run --rm -it mycompany/ansible-ee:1.4.2 ansible --version
Why this matters for interviews: "how do you guarantee the same Ansible behaviour across developers, CI and production?" has a weak answer and a strong one.

Weak: "we pin versions in requirements.yml." True but incomplete — it does not cover ansible-core or Python libraries.

Strong: "we build an Execution Environment with ansible-builder, pinning ansible-core, collections and Python dependencies into one image, and run it everywhere via ansible-navigator or AWX."

Execution Environments are the standard packaging model for Ansible Automation Platform, so naming them signals familiarity with how Ansible is actually run at scale rather than from a laptop.

ToolWhat it does
ansible-builderBuilds the EE image from execution-environment.yml
ansible-runnerThe library that actually executes Ansible inside the container
ansible-navigatorThe modern CLI — runs playbooks in an EE, with an interactive TUI for browsing results
You do not need an EE to work well. A pinned requirements.yml, a requirements.txt, and a virtualenv covers most teams. EEs earn their place when you have several teams, several projects with conflicting dependencies, or AWX in the picture. Know what they are and why they exist — that is what gets asked. (AWX itself is Module 12.)

Part E · Putting it together

E1 · Production practice

HabitWhy
group_vars/ inside each inventory directory, never at the rootRoot-level group_vars merges every environment — a staging edit reaches production
Pin every version in requirements.yml; commit itUnpinned means a different version per developer and per CI rebuild
collections_path = ./collections, gitignoredSelf-contained project; no dependence on a developer's ~/.ansible/
Commit the declaration, ignore what it installsCommitted dependencies bloat the repo and hide changes in huge diffs
Honour semver in your own collectionsA breaking change shipped as MINOR silently breaks everyone who pinned correctly
requires_ansible in meta/runtime.yml, set honestlyA clear failure on an old control node instead of a mysterious one
plugin_routing redirects when renaming anythingRenames stop being breaking changes for consumers
Build the tarball and install it locally before publishingCatches packaging bugs — a file excluded by build_ignore — that source-tree testing hides
FQCNs everywhere; skip the collections: keywordThe keyword reintroduces the ambiguity FQCNs exist to remove
Never default ansible.cfg's inventory to production silentlyA forgotten -i should be harmless, not an incident
Know what an Execution Environment is, even if you do not use oneIt is the only complete answer to reproducibility, and it gets asked

E2 · Capstone exercise

Attempt this without looking anything up. It exercises collection structure, versioning, repository layout and environment isolation together.

Brief. Restructure a flat project into a shareable, multi-environment repository:

  1. A collection mycompany.platform containing the webserver role from Module 05
  2. requires_ansible set, semver honoured, license valid so the build succeeds
  3. Two environments, production and staging, with genuinely isolated variables
  4. app_replicas = 6 in production and 1 in staging, with no conditional in the playbook
  5. A requirements.yml pinning both your collection and two public ones
  6. A .gitignore committing the declaration and ignoring what it installs
  7. The same playbook command differing only by the -i path
Model answer — attempt it first, then click
plain text
ansible-platform/
  ansible.cfg
  requirements.yml
  .gitignore
  playbooks/site.yml
  inventories/
    production/
      hosts.yml
      group_vars/all.yml         app_replicas: 6
    staging/
      hosts.yml
      group_vars/all.yml         app_replicas: 1
  collections/                   <- gitignored
yaml
# mycompany/platform/galaxy.yml
---
namespace: mycompany
name: platform
version: 0.1.0                        # requirement 2 - start low while unstable
readme: README.md
authors: ["Zaeem Mazhar <[email protected]>"]
description: Internal platform automation
license_file: LICENSE                 # requirement 2 - build fails without it
tags: [infrastructure, linux]
dependencies:
  ansible.posix: ">=1.5.0,<2.0.0"
repository: https://git.internal/ansible/platform
build_ignore:
  - .git
  - molecule
yaml
# mycompany/platform/meta/runtime.yml
---
requires_ansible: ">=2.15.0"          # requirement 2
yaml
# requirements.yml  -- requirement 5
---
collections:
  - name: https://git.internal/ansible/platform.git
    type: git
    version: v0.1.0
  - name: ansible.posix
    version: "1.5.4"
  - name: community.general
    version: "8.3.0"
plain text
# ansible.cfg
[defaults]
collections_path  = ./collections
roles_path        = ./roles
host_key_checking = True
stdout_callback   = yaml
# deliberately NO default inventory - forces -i to be explicit
yaml
# playbooks/site.yml  -- requirement 4: no conditional anywhere
---
- name: Configure the web tier
  hosts: web
  become: true
  roles:
    - mycompany.platform.webserver
  post_tasks:
    - name: Show what this environment resolved to
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} runs {{ app_replicas }} replicas"
plain text
# .gitignore  -- requirement 6
collections/
galaxy_roles/
*.retry
*.tar.gz
.vault_pass*
*.pem

The five decisions that matter:

  1. group_vars/ under each inventory directory — requirement 3. This is the entire mechanism; at the root, both environments would share one file.
  2. No conditional in the playbook — requirement 4. app_replicas differs purely because -i points elsewhere. If you found yourself writing when: env == 'prod', the layout is wrong.
  3. version: 0.1.0, not 1.0.0 — below 1.0.0 you are free to break the interface while it settles. Starting at 1.0.0 means every subsequent change is either a lie or a major bump.
  4. license_file set and the file presentansible-galaxy collection build fails without it, and the scaffolder leaves it empty.
  5. No default inventory in ansible.cfg — a forgotten -i fails loudly instead of silently choosing an environment.

Verify all seven:

bash
cd mycompany/platform && ansible-galaxy collection build --output-path /tmp/dist --force && cd -
ansible-galaxy collection install /tmp/dist/mycompany-platform-0.1.0.tar.gz -p ./collections --force
ansible-galaxy collection list

ansible-playbook -i inventories/staging    playbooks/site.yml   # 1 replica
ansible-playbook -i inventories/production playbooks/site.yml   # 6 replicas
ansible-playbook playbooks/site.yml                             # FAILS - no inventory
git status --short                                              # collections/ absent

E3 · Command reference — everything from this module

Commands from Module 06. ⭐ marks genuinely daily-use.

Collections — consuming

bash
ansible-galaxy collection list                                   # ⭐ what is installed, and where
ansible-galaxy collection list mycompany.platform                # one collection
ansible-galaxy collection install community.general              # newest
ansible-galaxy collection install community.general:==8.3.0      # ⭐ exact version
ansible-galaxy collection install -r requirements.yml            # ⭐ the reproducible way
ansible-galaxy collection install -r requirements.yml -p ./collections --force
ansible-galaxy install -r requirements.yml                       # ⭐ roles AND collections
ansible-galaxy collection verify community.general               # check against the signed source
ansible-config dump | grep -i collections_path                   # ⭐ where is it looking?

Collections — building

bash
ansible-galaxy collection init mycompany.platform                # ⭐ scaffold
ansible-galaxy collection build                                  # ⭐ -> .tar.gz
ansible-galaxy collection build --output-path ./dist --force
ansible-galaxy collection install ./dist/mycompany-platform-0.1.0.tar.gz -p ./collections --force
ansible-galaxy collection publish ./dist/*.tar.gz --api-key=$KEY

Discovering what a collection provides

bash
ansible-doc -l                                       # every module available now
ansible-doc -l mycompany.platform                    # ⭐ modules in one collection
ansible-doc -t role -l                               # roles with argument_specs
ansible-doc -t role mycompany.platform.webserver     # ⭐ a role's documented inputs
ansible-doc -t filter -l | grep mycompany            # custom filters
ansible-doc mycompany.platform.create_tenant         # ⭐ full module docs
find collections/ansible_collections -maxdepth 2 -type d   # what is actually on disk

Multi-environment runs

bash
ansible-playbook -i inventories/staging    playbooks/site.yml    # ⭐
ansible-playbook -i inventories/production playbooks/site.yml    # ⭐
ansible-inventory -i inventories/staging --graph                 # ⭐ verify the tree
ansible-inventory -i inventories/staging --host stg01            # ⭐ resolved variables
ansible-inventory -i inventories/production --list | head -40

Execution Environments

bash
pip install ansible-builder ansible-navigator
ansible-builder build --tag mycompany/ansible-ee:1.4.2 --file execution-environment.yml
ansible-builder create                                  # generate the build context only
ansible-navigator run playbooks/site.yml --eei mycompany/ansible-ee:1.4.2
ansible-navigator collections --eei mycompany/ansible-ee:1.4.2    # browse what is inside
podman run --rm -it mycompany/ansible-ee:1.4.2 ansible --version
The project bootstrap sequence — the first four commands on any repository you inherit:
bash
ansible-galaxy install -r requirements.yml      # get the pinned dependencies
ansible-config dump --only-changed              # what is this project configured to do?
ansible-inventory -i inventories/staging --graph  # what does it manage?
ansible-playbook -i inventories/staging playbooks/site.yml --list-tasks

None of them connect to a host or change anything, and together they tell you almost everything about an unfamiliar codebase.


E4 · Official documentation

LinkCovers
Using collectionsInstalling, requirements.yml, search paths, the collections: keyword
Developing collectionsBuilding, galaxy.yml, publishing, testing
Collection structureEvery directory and what belongs in it
meta/runtime.yml referencerequires_ansible, action_groups, plugin_routing
Sample Ansible setupThe official multi-environment directory layout
Galaxy user guiderequirements.yml syntax including git and tarball sources
Ansible Builder documentationexecution-environment.yml and building EE images
Ansible Navigator documentationRunning playbooks inside an Execution Environment
Semantic VersioningThe contract your collection version numbers are making

E5 · Self-assessment

1. What can a collection hold that a role cannot?

Modules, module_utils, and plugins of every kind — filter, lookup, inventory, callback, connection, action, test — plus playbooks and roles. A role is limited to tasks, handlers, templates, files, defaults and vars.

2. How does a directory in a collection become an FQCN?

plugins/modules/create_tenant.pymycompany.platform.create_tenant. roles/webserver/mycompany.platform.webserver. plugins/filter/ entries → mycompany.platform.<filter_name>.

The namespace and name come from galaxy.yml, and on disk everything lives under ansible_collections/<namespace>/<name>/.

3. What is plugin_routing for?

Renaming and removing content without breaking consumers. A redirect silently maps an old FQCN to a new one; a tombstone fails with a message naming the replacement.

It is the mechanism the Ansible project used during the 2.10 split, which is why bare yum: still resolves.

4. Why must group_vars/ live inside each inventory directory?

Because -i inventories/production loads that directory's inventory and its adjacent group_vars/host_vars automatically. That is what isolates environments.

At the repository root, every environment shares one set of variables, so a staging edit reaches production and the playbook needs conditionals to compensate — which is the smell that tells you the layout is wrong.

5. What do you commit and what do you ignore?

Commit ansible.cfg, a pinned requirements.yml, inventories, group_vars (secrets Vault-encrypted), playbooks, your own roles and lint config.

Ignore collections/, galaxy_roles/, vault password files, private keys, *.retry and build artifacts.

The principle: commit the declaration, ignore what it installs.

6. Why start a new collection at 0.1.0 rather than 1.0.0?

Below 1.0.0 the interface is understood to be unstable, so you can make breaking changes freely while it settles. Starting at 1.0.0 means every subsequent breaking change requires a major bump — or, more commonly, gets shipped dishonestly as a minor one.

7. Why test against a built tarball rather than your source tree?

Because the build applies build_ignore and produces exactly what a consumer receives. Testing against source hides packaging bugs — a file you forgot to include, or one you accidentally excluded — which then surface only after publishing.

8. Should you use the collections: keyword?

Generally no. It adds a search path for unqualified names, reintroducing the ambiguity FQCNs exist to remove — two listed collections defining the same name resolve by list order. ansible-lint flags short names. Use it only when maintaining code that already relies on it.

9. requirements.yml pins your collections. What is still unpinned?

ansible-core itself, the Python version, and every Python library a module needs — boto3, netaddr, kubernetes. Those are control-node dependencies that requirements.yml cannot express.

That gap is exactly what Execution Environments close.

10. What is an Execution Environment, and what are the three tools?

A container image bundling ansible-core, collections and Python dependencies into one immutable, reproducible runtime that behaves identically on a laptop, in CI and in AWX.

ansible-builder builds it from execution-environment.yml, ansible-runner executes Ansible inside it, and ansible-navigator is the CLI that runs playbooks against it.


Next — Module 07 · Secrets & Ansible Vault.

You now have a repository structure with group_vars/vault.yml sitting in it unencrypted. Module 07 fixes that — vault IDs, encrypting single variables versus whole files, CI integration, and the alternatives to Vault entirely.

📚 Sources for the interview questions

Behaviour verified against the current official collections documentation and developing collections guide.

Question selection cross-referenced against publicly published 2026 Ansible interview question sets:

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.