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.