Skip to main content
RunBook Academy

AnsibleXXXVII · Environments and Repository ArchitectureEnvironments and repository layout

Layering group_vars across environments

Intermediate⏱ ~19 minansible-core

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

Not yet marked complete on this device.

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

LayerWhere it livesWho owns itWhat belongs here
Role defaultroles/<role>/defaults/main.ymlThe role authorA working value for a host that has nothing special about it. The contract the role offers callers.
Environment baselineinventories/<env>/group_vars/all/Whoever owns the environmentFacts true of every host in this environment: the log target, the NTP servers, the environment name.
Group policyinventories/<env>/group_vars/<group>/The service ownerHow this tier behaves in this environment: worker counts, timeouts, feature flags.
Host exceptioninventories/<env>/host_vars/<host>.ymlWhoever accepted the exceptionOne 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:

Read-only / Safewithin-directory ordering, measured
$ 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:

Read-only / Safehost_vars beats the whole group_vars directory
$ 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:

Read-only / Safeevery value, attributed to its layer
$ 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:

Read-only / Safeproduction resolves its own values
$ ansible-playbook -i inventories/production playbooks/report.yml
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"
}
Read-only / Safestaging falls back to the role default
$ ansible-playbook -i inventories/staging playbooks/report.yml
ok: [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

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

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

  3. Q3. Running ansible-inventory --graph --vars against a production inventory with a vault id supplied will print decrypted secrets to standard output.

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