Skip to content

Inventory & variables

Scope: the course page shows one hand-written inventory/hosts.yml and a group_vars/all.yml. This page slows down on why inventory is split into groups and per-host/per-group variable files, and what dynamic inventory buys you over a static file. Its centerpiece is the full variable precedence ladder — the single most common source of "why did my override not take" confusion.

TL;DR — in 30 seconds:

  • Inventory is just hosts + groups + vars from any source — a play targeting hosts: web doesn't care whether that came from a hand-written YAML file or a live API query.
  • host_vars always beats group_vars, at any group specificity — there's no "more specific group wins" rule; host-level variables simply sit higher on the precedence ladder.
  • Command-line -e (extra-vars) is the highest-precedence override, by design — it's the explicit "I mean it, override everything" escape hatch for one run.

1. Inventory is just a source of hosts + groups + variables

Every Ansible run needs three things about its targets: which hosts, how they're grouped, and what variables apply to each. An inventory source — static file or dynamic plugin — supplies all three. Ansible doesn't care which source it came from; a play targeting hosts: web behaves identically whether web was typed by hand into YAML or discovered by querying an API a second ago.

Source How hosts are found Update mechanism Best fit
Static inventory Hand-written in hosts.yml (or INI) Edit the file Small, stable fleets — you know every host by name
Dynamic inventory Queried at run time from an external system (a cloud API, a CMDB) Automatic — reflects the source's current state Fleets that autoscale, get replaced, or are too large to hand-maintain

A dynamic inventory is a plugin (for example amazon.aws.aws_ec2) configured by a small YAML file whose name ends *.aws_ec2.yml; Ansible calls the plugin at the start of every run, and it returns hosts grouped by whatever the source exposes (tags, region, VPC). The trade-off: a static file is inspectable and diffable in a PR; a dynamic source is always current but only as trustworthy as the tags/metadata it reads from.

flowchart LR
    Static["Static hosts.yml<br/>(hand-maintained)"]
    Dynamic["Dynamic plugin<br/>(queries a live source)"]
    Parsed["Parsed inventory:<br/>hosts + groups + vars"]
    Play["A play's hosts: pattern"]

    Static --> Parsed
    Dynamic --> Parsed
    Parsed --> Play

2. Groups are how you avoid repeating yourself

A group is a named bucket of hosts. Two things make groups worth using beyond "list of names":

  • Targeting — a play's hosts: line can address a whole group (hosts: web) instead of enumerating every host, and supports set operations: web:&prod (intersection), web:!staging (exclusion).
  • Shared variables — a variable set at the group level applies to every member without repeating it per host.

Nested groups (the children: key) let you compose without duplicating host lists — a prod group that is simply web + db as children means adding a host to web automatically makes it part of prod too, with no second listing to keep in sync.

3. group_vars and host_vars — where variables actually live on disk

Two directories sit next to the inventory file, each holding one YAML file per group or per host:

inventory/
├── hosts.yml
├── group_vars/
│   ├── all.yml          # applies to every host
│   ├── web.yml          # applies to hosts in the `web` group
│   └── prod.yml         # applies to hosts in the `prod` group
└── host_vars/
    ├── web-01.yml        # applies only to web-01
    └── db-01.yml         # applies only to db-01

Ansible loads every matching file automatically — there's no include: to write. The naming is the wiring: a file named group_vars/web.yml applies to the web group purely because of its filename. This is also why a typo in a group or host name silently produces "my variable never applied": nothing errors, the file just never matches any host.

host_vars beats group_vars for the same variable name — a host-specific override always wins over whatever its groups set, which is exactly what you want for the one host that needs a different AMI user or a different resource size than the rest of its group.

4. Variable precedence — the full ladder

The course page's simplified precedence (command-line beats playbook beats role beats host/group beats role defaults) is the 80% you'll use daily. The table below condenses Ansible's full, 22-level precedence order to the 9 that matter once a fleet outgrows one inventory file, lowest to highest:

# Source Notes
1 Role defaults/main.yml Lowest. Meant to be overridden.
2 Inventory group vars (group_vars/all) Applies broadly.
3 Inventory group vars (other groups) More specific groups still lose to host_vars.
4 Inventory host_vars Beats every group-level source.
5 Playbook group vars / host vars (inline, rare) Same idea, defined inside the playbook instead of inventory files.
6 Play vars: Set directly on the play.
7 Role vars/main.yml Higher precedence than defaults — harder for a consumer to override.
8 set_fact / registered variables Computed during the run; wins over anything set before the task ran.
9 Command-line -e (extra-vars) Highest. Always wins, by design — it's the explicit "I mean it" override.
flowchart BT
    L1["1 · role defaults"]
    L2["2 · group_vars/all"]
    L3["3 · group_vars/<group>"]
    L4["4 · host_vars"]
    L5["5 · play vars"]
    L6["6 · role vars"]
    L7["7 · set_fact / register"]
    L8["8 · -e extra-vars"]

    L1 --> L2 --> L3 --> L4 --> L5 --> L6 --> L7 --> L8

    style L8 stroke-width:3px

Two practical consequences worth internalizing:

  • host_vars always beats group_vars, at any group specificity — there's no "more specific group wins" rule to reason about; host-level is simply above every group level.
  • -e on the command line always wins, over even role vars/. This is intentional: it's the escape hatch for "just this one run, override everything" (a hotfix, a debug session) without editing any file.

5. How this connects

The course page's single inventory/hosts.yml plus one group_vars/all.yml is the smallest version of this system: one source, one group-level file, no host overrides, no dynamic plugin. This page is what to reach for once a fleet grows past that — splitting variables by group and host instead of duplicating them, and knowing exactly which file wins when two of them disagree.

Common gotchas

  • Typo'ing a group or host name in a group_vars/host_vars filename. Fix: nothing errors — the file just silently never matches any host; if a variable "isn't applying," check the filename against the exact group/host name first.
  • Expecting a more specific group to beat a less specific one. Fix: group-level precedence isn't about specificity — every group_vars source loses to any host_vars source, regardless of nesting.
  • Reaching for a dynamic inventory plugin on a small, stable fleet "to be safe." Fix: a static file is diffable and reviewable in a PR; a dynamic source is only as trustworthy as the tags/metadata it reads — don't pay that cost without an autoscaling or too-large-to-hand-maintain fleet to justify it.
  • Forgetting -e on the command line beats everything, including role vars/. Fix: that's intentional — it's the one-off override for a hotfix or debug session — but it also means a stray -e in a saved script can silently mask every other precedence source.
Check yourself — you set a variable in group_vars/web.yml and override it in group_vars/prod.yml for a host in both groups. Which value wins, and why doesn't 'more specific group' decide it?

Ansible doesn't rank groups by specificity — both are the same precedence tier (inventory group vars), so the result depends on group evaluation order, not which group "sounds" more specific. The only variable-precedence fact you can rely on here is that a host_vars entry would beat either of them outright.

Check yourself — a teammate swears they set a variable in host_vars/web-03.yml, but the playbook uses the group default instead. Nothing errored. What's the most likely cause?

The filename doesn't exactly match the inventory hostname (a typo, wrong case, or a .yaml vs .yml mismatch) — Ansible loads host_vars/group_vars files purely by filename match, so a mismatch means the file is silently never read, with no error.

You can defend this when you can say why a host-level override always beats a group-level one regardless of group nesting, explain what a dynamic inventory plugin buys you over a static file (and what it costs in inspectability), and place -e, role defaults, and host_vars correctly on the precedence ladder without guessing.