Skip to main content
RunBook Academy

AnsibleXXXVII · Environments and Repository ArchitectureEnvironments and repository layout

The reference repository layout

Advanced⏱ ~22 minansible-coregit

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

Not yet marked complete on this device.

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:

Read-only / Safethe layout, read back
$ 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.com

Why 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.

estate/The reference layout, annotated with the failure each directory is there to prevent.
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 mapping
  1. 01ansible.cfg= committed at the repository root

    Fixes 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.

  2. 02inventories/<env>/= one directory per environment

    The 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.

  3. 03inventories/<env>/group_vars/= inside the environment directory

    Environment-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.

  4. 04playbooks/= entry points only, plus a tasks/ subdirectory

    Every 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.

  5. 05roles/= shared across every environment

    The 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.

  6. 06requirements.yml= collections and external roles, version-pinned

    Declares 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.

  7. 07collections/= gitignored install target

    Where 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.

  8. 08CODEOWNERS= path to reviewer mapping

    Requires 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 branchA branch per environment
How a change reaches productionMerge once; the change is present for all environments immediatelyMerge to staging, then merge staging into main
What a reviewer seesEvery environment’s version of the change, in one diffOne environment’s version; the others are a later merge
Risk profileA production-affecting mistake is visible at review time — and is also merged at review timeProduction is protected by a second merge — and the branches drift
DriftStructurally impossible; there is one copyThe 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 pathA commit on a specific branch, which may not contain fixes that exist on another
RollbackRevert the commitRevert 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

  1. 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"?

  2. Q2. Which of these are honest costs of the branch-per-environment pattern, rather than of the directory pattern? Select all that apply.

  3. Q3. Committing the installed collections/ directory is a reasonable way to make CI faster, because it removes a network dependency from the pipeline.

  4. 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.