AnsibleXXXVII · Environments and Repository ArchitectureEnvironments and repository layout
Environments are inventories, not variables
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
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.
| Environment | Question it answers | Fidelity it needs |
|---|---|---|
| Development | Does the automation run at all? | Low. One host per group is enough. Containers are fine. |
| Test / CI | Does the automation still do what it did yesterday? | Structural. Same group names, same role interface, disposable hosts. |
| Staging | Would this change be safe in production? | High. Same OS versions, same service topology, same variable set. |
| Production | — | It 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.
$ 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:
$ ansible-playbook envtest2.ymlTASK [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=0On 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:
$ ansible-playbook -i inventories/production playbooks/report.ymlTASK [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:
$ ansible-playbook -i inventories playbooks/report.ymlTASK [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=0Three things happened at once, and only the first is widely known.
- All four hosts merged into one run. Development and production are now in the same play.
- Every environment variable vanished.
environment_nameisUNSETon all four hosts. - Because the group variables are gone, every value fell back to the
role default —
workers=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”.
| Mistake | Design A: one inventory, env variable | Design B: one tree per environment |
|---|---|---|
--limit forgotten entirely | Every host in the estate | Every host in one environment |
| Environment group name typed wrong | Pattern matches nothing, exit 0, looks green | Same, but confined to one environment |
| Production hostname typed into a staging run | Resolves, and is changed | Does not resolve; run exits non-zero having done nothing |
| Environment variable unset on a new host | Host takes whichever branch the default() gives it | Not applicable; the host is in a file or it is not |
Variable named environment | Every guard inverted, silently | Not applicable |
-i pointed at the parent directory | Not applicable | All 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
failtask 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
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?
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.
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.
Q4. Which question best evaluates a proposed repository layout?
Passing score: 75%. Answers are checked in this browser.