Module 06 — Collections & Content Structure
Updated 20 August 2026
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
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.
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/That is the whole naming system from Module 01 Part A4, seen from the producing side rather than the consuming side.
A2 · galaxy.yml — the manifest
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.
---
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"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
---
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"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
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
- Collection mycompany.platform was created successfullymycompany
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/rolesnamespace: 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/repositoryNote 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:
- 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.
- 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.
- 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
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:#D97706ansible-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_KEYB2 · Using a git repository as the source
You do not need Galaxy or a Hub to distribute internally — a git tag is enough:
# requirements.yml
---
collections:
- name: https://git.internal/ansible/platform.git
type: git
version: v1.4.2 # a tag, branch or commit SHAansible-galaxy collection install -r requirements.yml -p ./collectionsB3 · Where collections are installed and found
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# ansible.cfg — the project-local convention
[defaults]
collections_path = ./collectionsproject/
ansible.cfg
requirements.yml
collections/ <- gitignored, rebuilt by CI
ansible_collections/
mycompany/platform/
community/general/
ansible/posix/The alternative — relying on the home directory — is how "works on my machine" happens in Ansible.
B4 · The collections: keyword
- 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: acmeansible-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
# 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# use-collection.yml
---
- hosts: web
become: true
roles:
- mycompany.platform.webserver # the FQCN, not a bare role name✅ Expected result — click to reveal
Created collection for mycompany.platform at /tmp/dist/mycompany-platform-0.1.0.tar.gzInstalling 'mycompany.platform:0.1.0' to '/home/zaeem/lab/collections/ansible_collections/mycompany/platform'
mycompany.platform:0.1.0 was installed successfully# /home/zaeem/lab/collections/ansible_collections
Collection Version
------------------- -------
mycompany.platform 0.1.0Look 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 FQCN — mycompany.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
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.
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/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.
ansible-playbook -i inventories/staging playbooks/site.yml
ansible-playbook -i inventories/production playbooks/site.ymlPointing -i at the directory loads the inventory file and its adjacent group_vars and host_vars automatically.
C2 · ansible.cfg for a real project
[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=60sThere is no universally right answer, but there is a wrong one: defaulting to production without the team knowing.
C3 · .gitignore
# 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/🧪 Exercise C3.1 — Prove that per-inventory group_vars are isolated
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
TASK [ansible.builtin.debug] ***************************************
ok: [stg01] => {
"msg": "stg01 -> STAGING with 1 replicas"
}TASK [ansible.builtin.debug] ***************************************
ok: [web01] => {
"msg": "web01 -> PRODUCTION with 6 replicas"
}{
"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
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.
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.
# 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-clientspip 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 --versionWeak: "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.
| Tool | What it does |
|---|---|
| ansible-builder | Builds the EE image from execution-environment.yml |
| ansible-runner | The library that actually executes Ansible inside the container |
| ansible-navigator | The modern CLI — runs playbooks in an EE, with an interactive TUI for browsing results |
Part E · Putting it together
E1 · Production practice
| Habit | Why |
|---|---|
| group_vars/ inside each inventory directory, never at the root | Root-level group_vars merges every environment — a staging edit reaches production |
| Pin every version in requirements.yml; commit it | Unpinned means a different version per developer and per CI rebuild |
| collections_path = ./collections, gitignored | Self-contained project; no dependence on a developer's ~/.ansible/ |
| Commit the declaration, ignore what it installs | Committed dependencies bloat the repo and hide changes in huge diffs |
| Honour semver in your own collections | A breaking change shipped as MINOR silently breaks everyone who pinned correctly |
| requires_ansible in meta/runtime.yml, set honestly | A clear failure on an old control node instead of a mysterious one |
| plugin_routing redirects when renaming anything | Renames stop being breaking changes for consumers |
| Build the tarball and install it locally before publishing | Catches packaging bugs — a file excluded by build_ignore — that source-tree testing hides |
| FQCNs everywhere; skip the collections: keyword | The keyword reintroduces the ambiguity FQCNs exist to remove |
| Never default ansible.cfg's inventory to production silently | A forgotten -i should be harmless, not an incident |
| Know what an Execution Environment is, even if you do not use one | It is the only complete answer to reproducibility, and it gets asked |
E2 · Capstone exercise
Brief. Restructure a flat project into a shareable, multi-environment repository:
- A collection mycompany.platform containing the webserver role from Module 05
- requires_ansible set, semver honoured, license valid so the build succeeds
- Two environments, production and staging, with genuinely isolated variables
- app_replicas = 6 in production and 1 in staging, with no conditional in the playbook
- A requirements.yml pinning both your collection and two public ones
- A .gitignore committing the declaration and ignoring what it installs
- The same playbook command differing only by the -i path
✅ Model answer — attempt it first, then click
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# 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# mycompany/platform/meta/runtime.yml
---
requires_ansible: ">=2.15.0" # requirement 2# 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"# 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# 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"# .gitignore -- requirement 6
collections/
galaxy_roles/
*.retry
*.tar.gz
.vault_pass*
*.pemThe five decisions that matter:
- group_vars/ under each inventory directory — requirement 3. This is the entire mechanism; at the root, both environments would share one file.
- 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.
- 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.
- license_file set and the file present — ansible-galaxy collection build fails without it, and the scaffolder leaves it empty.
- No default inventory in ansible.cfg — a forgotten -i fails loudly instead of silently choosing an environment.
Verify all seven:
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/ absentE3 · Command reference — everything from this module
Collections — consuming
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
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=$KEYDiscovering what a collection provides
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 diskMulti-environment runs
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 -40Execution Environments
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 --versionansible-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-tasksNone of them connect to a host or change anything, and together they tell you almost everything about an unfamiliar codebase.
E4 · Official documentation
| Link | Covers |
|---|---|
| Using collections | Installing, requirements.yml, search paths, the collections: keyword |
| Developing collections | Building, galaxy.yml, publishing, testing |
| Collection structure | Every directory and what belongs in it |
| meta/runtime.yml reference | requires_ansible, action_groups, plugin_routing |
| Sample Ansible setup | The official multi-environment directory layout |
| Galaxy user guide | requirements.yml syntax including git and tarball sources |
| Ansible Builder documentation | execution-environment.yml and building EE images |
| Ansible Navigator documentation | Running playbooks inside an Execution Environment |
| Semantic Versioning | The 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.py → mycompany.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.
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:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
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.