AnsibleXXXVII · Environments and Repository ArchitectureEnvironments and repository layout
The reference repository layout
What you'll learn
- Lay out a production Ansible repository and state the reason each directory exists
- Distinguish files that are entry points from files that are only ever included
- Choose between directories and branches for environment separation, on the merits
- Decide whether a role belongs vendored in the repository or pinned in requirements.yml
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
There is no universal Ansible repository layout, and any document that presents one as settled is hiding a set of trade-offs you will have to make anyway. What follows is a reference layout that works for the common case — one team, one estate, several environments — followed by the alternatives it rules out and why you might rule differently.
Copy the tree. Then read the section on alternatives before you commit to it, because two of the decisions embedded in it are genuinely contested.
The tree
estate/
ansible.cfg # committed; the run's behaviour, not the operator's
requirements.yml # collections and external roles, pinned
requirements.txt # ansible-core itself, pinned
README.md # written for someone paged at 03:00
CODEOWNERS # who reviews which paths
inventories/
production/
hosts.yml
group_vars/
all/
main.yml
vault.yml # encrypted, vault id: prod
webservers/
10-tuning.yml
20-limits.yml
host_vars/
web-prod-02.example.com.yml
staging/
hosts.yml
group_vars/
host_vars/
development/
hosts.yml
group_vars/
host_vars/
playbooks/
site.yml # entry point: the whole estate
webservers.yml # entry point: one tier
tasks/
_preflight.yml # NOT an entry point; imported only
roles/
webserver/
defaults/main.yml
tasks/main.yml
handlers/main.yml
templates/
meta/argument_specs.yml
common/
collections/ # installed by requirements.yml; gitignored
files/ # large static payloads shared across roles
scripts/ # helper tooling; never run by a play
molecule/ # role test scenarios
Point an inventory command at one environment directory and the tree
resolves without naming a single file — the recursive read picks up
hosts.yml and its group_vars/ neighbours together:
$ ansible-inventory -i inventories/production --vault-id prod@~/.vault/prod --graph@all:
|--@ungrouped:
|--@webservers:
| |--web-prod-01.example.com
| |--web-prod-02.example.com
|--@databases:
| |--db-prod-01.example.comWhy each directory exists
The useful question for a layout is not “is this tidy” but “which mistake does this make harder”. Each entry below answers that.
ansible.cfg: committed at the repository root
inventories/<env>/: one directory per environment
inventories/<env>/group_vars/: inside the environment directory
playbooks/: entry points only, plus a tasks/ subdirectory
roles/: shared across every environment
requirements.yml: collections and external roles, version-pinned
collections/: gitignored install target
CODEOWNERS: path to reviewer mapping01
ansible.cfg= committed at the repository rootFixes the run's behaviour - roles_path, inventory default, forks, callbacks - so every operator and the CI runner behave identically.
Production: Ansible reads the config from the current working directory, so this file is only picked up when the command is run from the repository root. Make that the documented rule and have CI do the same.
⚠ Setting inventory = inventories/ here. Every command that omits -i then loads all environments at once and silently drops every group_vars tree.
02
inventories/<env>/= one directory per environmentThe environment boundary. A host is in exactly one of these trees, and a run loads exactly one tree.
Production: Distinct filenames matter more than distinct group names. inventories/production/hosts.yml and inventories/staging/hosts.yml cannot be confused on a command line the way -l prod and -l stage can.
⚠ Pointing -i at inventories/ rather than inventories/production/. All environments merge and every group variable is dropped, because group_vars is searched adjacent to the source.
03
inventories/<env>/group_vars/= inside the environment directoryEnvironment-specific policy: which NTP servers, which log target, how many workers.
Production: Keeping it inside the environment directory is what makes the same variable resolve differently per environment without any branching in the role.
⚠ A group_vars/ beside the playbook instead. Ansible reads that one too, and it applies to every environment - so a staging convenience reaches production.
04
playbooks/= entry points only, plus a tasks/ subdirectoryEvery file directly in playbooks/ is something a human may legitimately run. Everything under playbooks/tasks/ is imported and never run directly.
Production: The convention is what makes ls playbooks/ a truthful answer to "what can I run against this estate". Prefix non-entry-point files with an underscore so the distinction survives a careless reader.
⚠ A partial task file sitting beside site.yml. Someone eventually runs it directly, it has no hosts: line of its own, and the result is either an error or a run against the wrong pattern.
05
roles/= shared across every environmentThe automation itself. Identical bytes for development and production - that is the entire premise of testing anywhere but production.
Production: A role should be parameterised, never environment-aware. If a role reads the environment name to decide behaviour, the production path is the one your staging run did not execute.
⚠ Copying a role to roles/webserver-prod/ for a production-only tweak. The two copies diverge, and the divergence is discovered during an incident.
06
requirements.yml= collections and external roles, version-pinnedDeclares the external code the automation depends on, at exact versions.
Production: This file is part of what gets promoted. A change that is "the same commit" but resolved a different collection version is not the same change.
⚠ Omitting version:. The default range identifier is *, meaning most recent - so the same commit installs different code next month.
07
collections/= gitignored install targetWhere ansible-galaxy places the collections named in requirements.yml, when you choose to install them into the project rather than the user path.
Production: Gitignore it and install in CI. Committing the installed tree makes diffs unreadable and hides the version pin behind thousands of vendored files.
⚠ Committing it "so CI is faster", then editing a file inside it to fix a bug. The fix is invisible to every reviewer and disappears at the next reinstall.
08
CODEOWNERS= path to reviewer mappingRequires a named reviewer for the paths whose breakage causes an outage.
Production: Scope it to the dangerous paths - inventories/production/, roles/ that manage storage or the network - rather than the whole repository. A CODEOWNERS matching everything is a queue, not a control.
⚠ Owning only the roles and leaving inventories/production/ unowned. Most wrong-target incidents are inventory changes, not role changes.
Contested decision 1: directories or branches per environment
Some teams put each environment on its own long-lived Git branch —
main is production, staging is staging — instead of in separate
directories on one branch. It is a real pattern with real adherents, and
it is worth understanding rather than dismissing.
| Directories on one branch | A branch per environment | |
|---|---|---|
| How a change reaches production | Merge once; the change is present for all environments immediately | Merge to staging, then merge staging into main |
| What a reviewer sees | Every environment’s version of the change, in one diff | One environment’s version; the others are a later merge |
| Risk profile | A production-affecting mistake is visible at review time — and is also merged at review time | Production is protected by a second merge — and the branches drift |
| Drift | Structurally impossible; there is one copy | The normal outcome. A hotfix on main that is never back-merged is invisible until the next promotion conflicts |
| “What is production running?” | A commit plus an inventory path | A commit on a specific branch, which may not contain fixes that exist on another |
| Rollback | Revert the commit | Revert on the branch, then reconcile the others |
The honest summary: branches trade an immediate risk for a slower, quieter one. The immediate risk is that on a directory layout a merged change is live for production the moment production next runs. The quieter risk is that branches diverge, and divergence is discovered at the worst time — during a promotion, under pressure, when the merge conflict is in a file nobody remembers.
The directory layout handles the immediate risk with the thing that is
actually load-bearing: production runs from a tag, not from whatever
main happens to contain. That decouples “merged” from “deployed”
without creating a second copy of the automation. Lesson 5 and Part
XXXVIII’s branching lesson cover the mechanics.
Choose branches only if you have a concrete reason the directory form cannot serve — for example, two environments genuinely running different major versions of the automation for a long migration. Then treat the divergence as a project with an end date, not a permanent structure.
Contested decision 2: one repository or several
The reference layout is one repository. The alternatives are worth naming because the pressure to split arrives around the time the repository gets useful.
Split roles into their own repositories when a role is consumed by
more than one estate, or by a team that does not own this repository.
Then it is a dependency with a version, pulled in through
requirements.yml, and it gets its own tests and its own release
cadence. That is a genuine benefit and a genuine cost: every change now
requires a release and a bump, which is exactly the friction you want for
shared code and exactly the friction you do not want for code only you
use.
Split inventory from automation when the people who add and remove hosts are not the people who write plays — a common shape once an estate has a provisioning team. The inventory repository then has its own review rules and its own change rate, which is usually much higher.
Do not split per environment. Two repositories, one for staging and one for production, is the branch-drift problem with worse tooling: there is no merge to conflict, so the divergence is completely silent.
Contested decision 3: vendored roles or requirements.yml
For a role you did not write, there are two options and one of them is usually wrong.
Pinned in requirements.yml is the default answer:
---
collections:
- name: community.general
version: "==11.4.0"
- name: ansible.posix
version: ">=3.0.0,<4.0.0"
roles:
- name: geerlingguy.java
version: "1.9.6"
The version key takes the range identifiers *, !=, ==, >=, >,
<= and <, and they can be combined with a comma. Ranges work for
collections; for roles, only an exact version is supported. Omitting
version entirely means * — most recent — which is how a repository
that has not changed in six weeks starts behaving differently.
Vendored into roles/ is right in narrow cases: the upstream is
unmaintained, or your estate needs a fork you cannot upstream, or you are
in an air-gapped environment where the install step cannot reach a galaxy
server. In every one of those, record why in the role, because the
reason is what tells a future maintainer whether the fork can be
retired.
What you should not do is vendor a role because pinning felt like extra
work, and you should not commit the collections/ install target for the
same reason. Both hide the version behind a wall of files that no reviewer
will read.
Knowledge check
Knowledge check · 4 questions
Q1. A requirements.yml lists a collection with no version key. What does that mean for a promotion that claims "the same commit ran in staging and production"?
Q2. Which of these are honest costs of the branch-per-environment pattern, rather than of the directory pattern? Select all that apply.
Q3. Committing the installed collections/ directory is a reasonable way to make CI faster, because it removes a network dependency from the pipeline.
Q4. A play names the role webserver. roles/webserver/ exists in the repository and a collection installed via requirements.yml also ships a role by that name. What runs, and what warns you?
Passing score: 75%. Answers are checked in this browser.