Skip to main content
RunBook Academy

AnsibleXXXVII · Environments and Repository ArchitectureEnvironments and repository layout

Environments are inventories, not variables

Advanced⏱ ~18 minansible-core

What you'll learn

  • State what each of the four common environments is for, and derive its required fidelity from that purpose
  • Explain why an environment carried as a variable inside one inventory cannot enforce anything
  • Recognise the reserved variable name that silently inverts every environment guard
  • Evaluate a proposed layout against the property that a staging run must not be able to reach production

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.

Every estate that runs Ansible seriously ends up with more than one set of machines: somewhere to try things, somewhere to prove things, and the machines that customers actually touch. The question this part answers is not whether to have environments. It is where the boundary between them lives, and whether that boundary is enforced by a machine or remembered by a person.

There is one property that decides whether a layout is good, and it is worth stating before any directory names:

It must be impossible to run a staging playbook against production by accident.

Not “unlikely”. Not “against convention”. Impossible in the sense that the mistake produces an error rather than a change. Everything else in this part — layout, promotion, vault ids, ownership — is downstream of that one requirement, and every design decision should be tested against it rather than against how tidy the tree looks.

What each environment is actually for

The names are conventional; the purposes are not interchangeable, and the purpose is what decides how much the environment has to resemble production.

EnvironmentQuestion it answersFidelity it needs
DevelopmentDoes the automation run at all?Low. One host per group is enough. Containers are fine.
Test / CIDoes the automation still do what it did yesterday?Structural. Same group names, same role interface, disposable hosts.
StagingWould this change be safe in production?High. Same OS versions, same service topology, same variable set.
ProductionIt is the thing being protected.

Most estates have three of these; some have four; a few have production plus a single pre-production tier and are entirely honest about it. What goes wrong is not the count. What goes wrong is an environment whose fidelity does not match its claim: a staging tier that lacks a group production has, or that resolves a variable to a different value, proves nothing about the change it was asked to prove. Part XXVI’s staging lesson makes that argument in full; this part’s job is to make the structure support it.

The two designs

Design A — one inventory, an environment variable. All hosts live in one tree. Each host or group carries something like env: prod or environment_name: staging, and plays either target a group named production or branch on the variable.

Design B — one inventory tree per environment. inventories/production/, inventories/staging/, inventories/development/. Roles, playbooks and collections are shared; only the inventory differs. This is the layout the next lesson walks through in full.

Design A is smaller. That is its entire advantage, and it is real: one file to edit, one command to ask a fleet-wide question. Design B costs a directory per environment and makes cross-environment reporting an explicit act.

The reason to pay that cost is that Design A cannot satisfy the property at the top of this lesson. Under Design A, safety is a value the play reads. A value can be missing, stale, overridden, or — as the next section shows — reserved.

The reserved name that inverts the guard

This is the trap that makes the variable approach worse than merely weak, and it fires on the most obvious name anybody would reach for.

environment is a playbook keyword: it sets environment variables for the tasks it applies to. Defining a variable with that name does not override the keyword; it produces a warning and resolves to something you did not write.

Read-only / Safewhat environment actually resolves to
$ ansible-playbook envtest.yml
[WARNING]: Found variable using reserved name 'environment'.
Origin: /tmp/envtest.yml:6:5

4   connection: local
5   vars:
6     environment: production
    ^ column 5

PLAY [environment as a variable name] ******************************************

TASK [Show it] *****************************************************************
ok: [localhost] => {
  "msg": "env is []"
}

production went in; [] came out. Now put a guard on it, which is the only reason anyone defines the variable in the first place:

Read-only / Safethe guard fires the wrong way
$ ansible-playbook envtest2.yml
TASK [Only in production] ******************************************************
skipping: [localhost]

TASK [Everywhere except production] ********************************************
ok: [localhost] => {
  "msg": "NON-PRODUCTION PATH"
}

PLAY RECAP *********************************************************************
localhost  : ok=1  changed=0  unreachable=0  failed=0  skipped=1  rescued=0  ignored=0

On a host explicitly declared as production, the production-only task was skipped and the everything-except-production task ran. The play did not fail. The recap is green. The warning scrolled past twenty lines ago.

What separate trees enforce, and how it gets undone

Under Design B the environment is not a value; it is which file the inventory loader read. A production hostname passed to a staging run does not resolve, because it is not in the loaded source. Part V’s inventory lesson demonstrates that refusal and the exit codes around it; this lesson assumes it and moves to the failure mode that layout introduces.

Here is a three-environment tree with the variables in the right place:

estate/
  ansible.cfg
  playbooks/
  roles/
    webserver/
      defaults/main.yml       # webserver_worker_processes: 2
  inventories/
    production/
      hosts.yml
      group_vars/all/main.yml         # environment_name: production
      group_vars/webservers/*.yml     # webserver_worker_processes: 16
      host_vars/
    staging/
      hosts.yml
      group_vars/all/main.yml         # environment_name: staging
    development/
      hosts.yml
      group_vars/all/main.yml         # environment_name: development

Point -i at one environment and everything resolves as designed:

Read-only / Safeone role, two environments, correct values
$ ansible-playbook -i inventories/production playbooks/report.yml
TASK [webserver : Report the effective parameter set] **************************
ok: [web-prod-01.example.com] => {
  "msg": "env=production workers=16 tls=False"
}
ok: [web-prod-02.example.com] => {
  "msg": "env=production workers=32 tls=False"
}

Now the mistake. Somebody points -i one level higher — at inventories/ rather than inventories/production/. It is a single missing path component, it is the kind of thing that happens in a wrapper script or a CI variable, and this is what it produces:

Read-only / Safethe parent-directory mistake
$ ansible-playbook -i inventories playbooks/report.yml
TASK [webserver : Report the effective parameter set] **************************
ok: [web-dev-01.example.com] => {
  "msg": "env=UNSET workers=2 tls=False"
}
ok: [web-prod-01.example.com] => {
  "msg": "env=UNSET workers=2 tls=False"
}
ok: [web-prod-02.example.com] => {
  "msg": "env=UNSET workers=2 tls=False"
}
ok: [web-stg-01.example.com] => {
  "msg": "env=UNSET workers=2 tls=False"
}

PLAY RECAP *********************************************************************
web-dev-01.example.com   : ok=1  changed=0  unreachable=0  failed=0
web-prod-01.example.com  : ok=1  changed=0  unreachable=0  failed=0
web-prod-02.example.com  : ok=1  changed=0  unreachable=0  failed=0
web-stg-01.example.com   : ok=1  changed=0  unreachable=0  failed=0

Three things happened at once, and only the first is widely known.

  1. All four hosts merged into one run. Development and production are now in the same play.
  2. Every environment variable vanished. environment_name is UNSET on all four hosts.
  3. Because the group variables are gone, every value fell back to the role defaultworkers=2, the development-sized value — including on the two production hosts that were tuned to 16 and 32.

The recap is entirely green. Had the role been a real one, production would now be running the development configuration and the run would have reported ok for every host.

Testing a proposed layout against the property

When somebody proposes a structure, do not argue about the directory names. Ask what each of these produces, and prefer the layout where the answer is “an error”.

MistakeDesign A: one inventory, env variableDesign B: one tree per environment
--limit forgotten entirelyEvery host in the estateEvery host in one environment
Environment group name typed wrongPattern matches nothing, exit 0, looks greenSame, but confined to one environment
Production hostname typed into a staging runResolves, and is changedDoes not resolve; run exits non-zero having done nothing
Environment variable unset on a new hostHost takes whichever branch the default() gives itNot applicable; the host is in a file or it is not
Variable named environmentEvery guard inverted, silentlyNot applicable
-i pointed at the parent directoryNot applicableAll environments merge and all group vars are dropped

Design B is not perfect — the last row is a genuine sharp edge, and it is the one this part keeps returning to. But its failures are visible in the host count and the resolved variables, while Design A’s failures are visible only in the aftermath.

Where the environment name still belongs

Nothing above says you should have no environment variable at all. It says the variable must not be the boundary.

environment_name: production in inventories/production/group_vars/all/ is genuinely useful, because it is derived from the tree rather than enforcing it:

  • It goes into a managed-file header, so a config on disk says which environment’s automation wrote it.
  • It goes into the run log and the notification, so an audit trail can be read months later.
  • It lets a fail task refuse to proceed if the environment is not the one the play expects — a second lock on the same door, not the door.

What it must never do is decide behaviour inside a role. That is lesson 4, and it is the antipattern that makes production the least-tested path in the estate.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play sets environment: production in vars and guards a task with when: environment != "production". On ansible-core 2.21.3, what happens on a production host?

  2. Q2. Running with -i inventories/ rather than -i inventories/production/ produced a green run against all four hosts. Which statements about that run are correct? Select all that apply.

  3. Q3. A staging environment with far lower fidelity than production is still worth keeping as a separate inventory tree, provided the repository records what it does not cover.

  4. Q4. Which question best evaluates a proposed repository layout?

Passing score: 75%. Answers are checked in this browser.