AnsibleXXXVII · Environments and Repository ArchitectureEnvironments and repository layout
Layering group_vars across environments
What you'll learn
- Place a value in the correct layer by asking who owns it rather than where it fits
- Predict which file wins inside a group_vars/<group>/ directory of several files
- Use ansible-inventory --graph --vars to attribute a resolved value to its layer
- Recognise the resolution that inventory inspection cannot show you
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
Ansible has twenty-two variable precedence levels. Part XIII covers all of them and you should read it before you need it. This lesson is about the four that a repository layout actually controls, and about the only question that matters when deciding where a value goes.
That question is not “where does this fit”. It is who owns this value, and who has to approve a change to it.
Four layers, four owners
| Layer | Where it lives | Who owns it | What belongs here |
|---|---|---|---|
| Role default | roles/<role>/defaults/main.yml | The role author | A working value for a host that has nothing special about it. The contract the role offers callers. |
| Environment baseline | inventories/<env>/group_vars/all/ | Whoever owns the environment | Facts true of every host in this environment: the log target, the NTP servers, the environment name. |
| Group policy | inventories/<env>/group_vars/<group>/ | The service owner | How this tier behaves in this environment: worker counts, timeouts, feature flags. |
| Host exception | inventories/<env>/host_vars/<host>.yml | Whoever accepted the exception | One machine that differs, with a reason. Every entry here is a small debt. |
Read down that table and the reason for the ordering is obvious: each layer is narrower and better-informed than the one above, so each one wins over the one above. Read up it and you get the review rule — a change at a lower layer affects fewer hosts and needs fewer approvals, which is exactly why host_vars is where a “temporary” fix goes to become permanent.
A group_vars directory of several files
Any group_vars/<group>.yml file can become a group_vars/<group>/
directory instead, and every parseable file inside it is read. This is how
you keep a large tier’s variables readable, and how the encrypted material
sits beside the plaintext material without one file being half-encrypted.
inventories/production/group_vars/webservers/
10-tuning.yml # webserver_worker_processes: 4
# webserver_max_body_size: 16m
20-limits.yml # webserver_worker_processes: 16
vault.yml # encrypted, vault id: prod
Files inside the directory are read in lexicographic order and later
files win. That is what the numeric prefixes are for, and it is why
20-limits.yml beats 10-tuning.yml:
$ ansible-inventory -i inventories/production --host web-prod-01.example.com{
"ansible_host": "192.0.2.11",
"environment_name": "production",
"ntp_servers": [
"ntp1.example.com",
"ntp2.example.com"
],
"webserver_max_body_size": "16m",
"webserver_worker_processes": 16
}webserver_max_body_size survives from 10-tuning.yml because nothing
later redefines it; webserver_worker_processes is 16 because
20-limits.yml was read after 10-tuning.yml. Add a host exception and
it wins over both:
$ ansible-inventory -i inventories/production --host web-prod-02.example.com{
"ansible_host": "192.0.2.12",
"environment_name": "production",
"ntp_servers": [
"ntp1.example.com",
"ntp2.example.com"
],
"webserver_max_body_size": "16m",
"webserver_worker_processes": 32
}Finding where a value came from
This is the question you will actually ask, usually at speed, usually because production has a value nobody expected. There is one command that answers most of it:
$ ansible-inventory -i inventories/production --vault-id prod@~/.vault/prod --graph --vars@all:
|--@ungrouped:
|--@webservers:
| |--web-prod-01.example.com
| | |--{ansible_host = 192.0.2.11}
| | |--{environment_name = production}
| | |--{ntp_servers = ['ntp1.example.com', 'ntp2.example.com']}
| | |--{webserver_max_body_size = 16m}
| | |--{webserver_worker_processes = 16}
| |--web-prod-02.example.com
| | |--{ansible_host = 192.0.2.12}
| | |--{environment_name = production}
| | |--{webserver_max_body_size = 16m}
| | |--{webserver_worker_processes = 32}
| |--{webserver_max_body_size = 16m}
| |--{webserver_worker_processes = 16}
|--{environment_name = production}
|--{ntp_servers = ['ntp1.example.com', 'ntp2.example.com']}Read it bottom-up. The lines hanging off @all came from
group_vars/all/. The two hanging off @webservers came from
group_vars/webservers/. The per-host lines are the fully resolved set,
so web-prod-02 showing 32 while the group shows 16 tells you
immediately that a host_vars file exists for that machine.
That attribution is the thing --host cannot give you: --host returns
the resolved answer, --graph --vars returns the answer and the layer.
The same variable, three environments
The point of the whole arrangement is that one line in a role works everywhere. Same playbook, same role, two inventories:
$ ansible-playbook -i inventories/production playbooks/report.ymlok: [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"
}$ ansible-playbook -i inventories/staging playbooks/report.ymlok: [web-stg-01.example.com] => {
"msg": "env=staging workers=2 tls=False"
}Nothing failed. Both runs are green. And staging just tested a configuration production does not use — 2 workers against production’s 16 and 32.
That asymmetry is not always wrong; staging is smaller and a smaller worker count may be correct. It is wrong when the difference is accidental, which is the usual case, and it is the mechanism behind this part’s first break/fix: a change validated in staging behaved differently in production because the two environments resolved different values for the parameter the change was about. Lesson 4 is about the discipline that prevents it.
Knowledge check
Knowledge check · 4 questions
Q1. group_vars/webservers/ contains 10-tuning.yml setting workers to 4 and 20-limits.yml setting it to 16. Someone adds alerts.yml setting it to 8. What does a host in that group resolve to, with no host_vars?
Q2. A play sees webserver_worker_processes = 4, but ansible-inventory --graph --vars shows 16 for that host. Which explanations are consistent with both observations? Select all that apply.
Q3. Running ansible-inventory --graph --vars against a production inventory with a vault id supplied will print decrypted secrets to standard output.
Q4. You find the same byte-identical group_vars file in the production, staging and development trees. What does that most likely indicate?
Passing score: 75%. Answers are checked in this browser.