AnsibleV · Inventory Design at Fleet ScaleFleet taxonomy
Environment separation you can rely on
What you'll learn
- Separate environments at the inventory-source level rather than by group membership
- Explain why an absent host is a stronger control than an excluded one
- Distinguish a pattern that matches nothing from a --limit that leaves nothing
- Identify the three ways environment separation is accidentally undone
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
There are two ways to keep a staging change away from production, and they are not equally strong.
The common one is to put both environments in one inventory, in groups
called production and staging, and rely on everyone always targeting
the right group. The other is to put them in separate inventory sources,
so that when the staging run is loaded, production hosts do not exist.
The second one costs a directory. It is worth far more than it costs.
Excluded is not the same as absent
Under a single shared inventory, safety is a property of the command
line. ansible-playbook site.yml --limit staging is safe; the same
command with the limit forgotten is a fleet-wide production change. The
inventory permitted both. Something outside the inventory — a habit, a
wrapper script, a code review — was the only thing standing between them.
Under separate sources, safety is a property of the data. The staging inventory contains staging hosts. A production hostname typed into a staging run does not resolve, because it is not in the file that was loaded.
This distinction has a name in security engineering and it is worth importing: exclusion is a filter, absence is a boundary. Filters are applied by whoever remembers to apply them.
estate/
ansible.cfg
site.yml
roles/ # shared: the automation itself
inventory/
production/
hosts.yml # only production hosts
group_vars/
staging/
hosts.yml # only staging hosts
group_vars/
development/
hosts.yml
group_vars/
Roles and playbooks are shared, because the whole point of staging is to run the same automation against different machines. Inventory is not shared, because the whole point of an environment is that its machines are different machines.
What the boundary actually does
Load the staging inventory and ask for everything:
$ ansible -i inventory/staging 'all' --list-hosts hosts (2):
web-s1.example.com
db-s1.example.comNow try to reach a production host from that run — the mistake this design exists to stop:
$ ansible -i inventory/staging 'all' --limit web-a1.example.com --list-hosts[WARNING]: Could not match supplied host pattern, ignoring: web-a1.example.com
[ERROR]: Specified inventory, host pattern and/or --limit leaves us with no hosts to target.Exit status 1, no tasks, nothing attempted. There is no version of that command that reaches production, because the name means nothing to the loaded inventory.
The honest comparison
Separate inventories are not free, and pretending otherwise makes the argument weaker than it is.
| Single inventory, environment groups | Separate inventory sources | |
|---|---|---|
| Cost of a fleet-wide question | One command over everything | One command per environment, or an explicit merge |
| Group definitions | Written once | Written once per environment |
| Cross-environment reporting | Trivial | Requires passing -i twice on purpose |
A forgotten --limit | Changes production | Changes only the loaded environment |
| A typo in an environment group name | Silently widens the target | Cannot widen past the file |
| Onboarding a new engineer | They must learn the discipline | The tool enforces it |
The first three rows are real costs. Row four is why you pay them.
The duplication in row two is smaller than it looks, because what is
duplicated is membership, which is genuinely different per environment,
not behaviour, which lives in roles and is shared. If you find yourself
copying the same group_vars content between environments, that content
is not environment-specific and belongs in the role’s defaults/
instead.
The three ways separation gets undone
Every one of these has happened in real estates, and none of them announce themselves.
1. -i pointed at the parent directory. Shown above. Five hosts
appear where two were expected. The tell is the host count in
--list-hosts; if you never look at it, there is no tell.
2. A default inventory in ansible.cfg that spans environments.
Setting inventory = inventory/ in the project config means every command
that omits -i silently loads everything. The fix is to make the default
either the safest environment or nothing at all, and to require -i for
production. A default that includes production is a default that turns a
forgotten flag into an incident.
3. A shared group_vars/all.yml outside the environment
directories. Ansible looks for group_vars/ beside the inventory
source and beside the playbook. A group_vars/all.yml sitting next to
site.yml applies to every environment, so a variable that was meant to
be a staging convenience — a shortened timeout, a relaxed check, a test
endpoint — reaches production too. Keep group_vars/ inside the
environment directory, and treat a playbook-adjacent group_vars/ as a
finding in review.
Promotion works the same either way
A concern people raise about separate inventories is promotion: if the environments are separate, how do you know the staging test proves anything about production?
The answer is that promotion has never been about inventory. What gets promoted is the automation — the roles, the playbook, the pinned collection versions, the variable defaults — and that is shared already. The inventory is deliberately different, because staging hosts are deliberately different machines.
What you do owe is structural equivalence: if production has web,
database and cache groups, staging should have all three, even if each
holds one host. A staging environment missing a group is a staging
environment that cannot exercise the play that targets it, and you will
discover that during the production run.
Knowledge check
Knowledge check · 4 questions
Q1. Why is a separate inventory source per environment a stronger control than a production group in a shared inventory?
Q2. Pointing -i at the parent directory that contains both inventory/production and inventory/staging merges every environment into a single inventory.
Q3. Which of these silently undo environment separation? Select all that apply.
Q4. A nightly job runs ansible-playbook -i inventory/production site.yml --limit canary. The canary group was renamed last month and no longer exists. What does the operator see?
Passing score: 75%. Answers are checked in this browser.