Ansible Deep Dive Part 2: Inventory — Static, Dynamic, and Everything In Between
Part 1 used a five-line INI file as an inventory. That’s fine for a lab. It falls apart the moment you have more than about twenty hosts, more than one environment, or infrastructure that changes shape on its own (autoscaling groups, DHCP-leased lab gear, a CMDB that’s the actual source of truth). This post covers the inventory system properly: static files in both formats, group structure, variable attachment, and the dynamic inventory plugins that replace all of it.
Static inventory, INI and YAML
The INI format from Part 1 extends naturally into groups:
[webservers]
web1.lab.local
web2.lab.local
[dbservers]
db1.lab.local
[lab:children]
webservers
dbservers
[webservers:vars]
http_port=8080
lab:children creates a parent group containing both child groups — useful for “run against everything in this environment” without listing hosts twice. webservers:vars attaches a variable to every host in that group.
I actually prefer the YAML format for anything beyond a trivial lab, because nested groups and variables read far more clearly:
# inventory.yml
all:
children:
lab:
children:
webservers:
hosts:
web1.lab.local:
web2.lab.local:
vars:
http_port: 8080
dbservers:
hosts:
db1.lab.local:
db_role: primary
db2.lab.local:
db_role: replica
Same structure, but host-specific variables (db_role differing between the two DB hosts) are obviously placed next to the host they belong to, which the INI format makes awkward.
Two built-in groups you get for free
all contains every host in the inventory. ungrouped contains any host not assigned to another group. You never define these — Ansible creates them automatically — but they show up constantly in --limit expressions and in hosts: fields inside playbooks.
Variables belong in host_vars/ and group_vars/, not the inventory file
Once a project has more than a handful of variables, cramming them into the inventory file itself gets unreadable fast. The convention — and I’d call this non-negotiable best practice rather than a style preference — is a directory structure Ansible auto-loads:
inventory/
hosts.yml
group_vars/
all.yml
webservers.yml
dbservers.yml
host_vars/
db1.lab.local.yml
Ansible loads group_vars/all.yml for every host, group_vars/webservers.yml for hosts in that group, and host_vars/<hostname>.yml for that specific host — with host_vars taking precedence over group_vars, which takes precedence over group_vars/all. This directory layout is picked up automatically as long as it sits next to the inventory file; no extra configuration needed. I cover the full precedence order — because host_vars vs group_vars is only two rungs of an eleven-rung ladder — in Part 6, alongside Vault.
Patterns: choosing who a command or playbook targets
The hosts: line in a playbook, and the --limit flag on the command line, both accept patterns:
ansible webservers -m ping # a group
ansible 'webservers:&lab' -m ping # intersection: in both groups
ansible 'webservers:!web2.lab.local' -m ping # webservers except web2
ansible 'web*.lab.local' -m ping # wildcard
ansible all --limit dbservers -m ping # limit narrows whatever hosts: specified
--limit is the one I reach for constantly during testing — write the playbook against hosts: all, then run it with --limit web1.lab.local while you’re still working out the kinks, so a mistake doesn’t roll out fleet-wide.
Dynamic inventory: when the source of truth isn’t a file you maintain
Static files are a liability the moment the truth lives somewhere else — a cloud provider’s API, a CMDB, or (for network engineers) a NetBox instance that already tracks every device, its role, and its site. Ansible’s dynamic inventory plugins query that source at run time and build the in-memory inventory Ansible actually uses, with zero manual file maintenance.
AWS EC2 is the canonical example:
# inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- eu-west-2
keyed_groups:
- key: tags.Role
prefix: role
- key: instance_type
prefix: type
compose:
ansible_host: public_ip_address
Run ansible-inventory -i inventory/aws_ec2.yml --graph and Ansible queries the EC2 API live, groups instances by their Role and instance_type tags automatically, and you never touch a host list again — terminate an instance and it silently disappears from the next run, no orphaned entry to clean up.
For network estates the same pattern applies against NetBox:
# inventory/netbox.yml
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.lab.local
token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
group_by:
- device_roles
- site
This is the approach I’d push any network team toward once they’re past a handful of devices: NetBox (or whatever CMDB you already run) becomes the single source of truth, and Ansible’s job is purely to act on it, never to store it. A device gets added in NetBox once, by whoever owns that process, and every playbook sees it on the next run without anyone touching Ansible’s configuration at all.
Sanity-checking an inventory before you run anything against it
Two commands I run before every unfamiliar inventory:
ansible-inventory -i inventory.yml --list # full JSON dump, all vars resolved
ansible-inventory -i inventory.yml --graph # human-readable group tree
--graph is the fast visual check that a dynamic plugin grouped things the way you expected. --list is what to grep through when a variable isn’t resolving the way you think it should — it shows the final, fully-merged value Ansible will actually use, after all the precedence rules have been applied.
Part 3 moves from who Ansible talks to, into what it does when it gets there — plays, tasks, modules, and the idempotency contract that makes it safe to run the same playbook against production twice.