Most engineers think…
Most engineers think "inventory" just means a text file of IP addresses you keep next to your playbook — write it once, point Ansible at it, done. So they keep editing hosts.ini by hand forever.
Wrong — and that habit quietly breaks automation at scale. An inventory is a source of truth about which hosts exist and how to reach them. For a fixed lab, a static file is fine. But in cloud and dynamic networks the truth lives in AWS, Azure, GCP or NetBox — not your text file — and it changes hourly. The real skill is a dynamic inventory plugin that asks that source for the live host list at run time, then auto-builds your groups with keyed_groups and compose. The text file is the training wheels; the plugin is the bike.
① Static inventory — the INI/YAML host list, groups, children & ranges
Start where everyone starts. Inventory is just Ansible's answer to one question: which machines do I manage, and how do I reach each one? The simplest answer is a static inventory — a text file you write by hand. Rahul, an L1 engineer at Infosys, manages 12 access switches and 6 servers in one branch, so a static file is perfect for him. The host list rarely changes, and he can read the whole thing at a glance.
There are two formats, and both are first-class. INI is the classic — flat, fast to read, great for a small estate. YAML is the structured one — it handles nested groups and typed variables (lists, dicts, booleans) far more cleanly. Same inventory, two spellings:
# hosts.ini [webservers] web1.infosys.local web2.infosys.local [dbservers] db1.infosys.local # nested group: a group OF groups, via :children [branch_pune:children] webservers dbservers # group variables [webservers:vars] ansible_user=svc_ansible
$ ansible-inventory -i hosts.ini --list --yaml | head
all:
children:
branch_pune:
children:
webservers: {hosts: {web1.infosys.local: ..., web2.infosys.local: ...}}
dbservers: {hosts: {db1.infosys.local: ...}}# hosts.yml
all:
children:
branch_pune:
children:
webservers:
hosts:
web1.infosys.local:
web2.infosys.local:
vars:
ansible_user: svc_ansible
dbservers:
hosts:
db1.infosys.local:$ ansible-inventory -i hosts.yml --graph @all: |--@branch_pune: | |--@webservers: | | |--web1.infosys.local | | |--web2.infosys.local | |--@dbservers: | | |--db1.infosys.local
Two groups exist automatically, in every inventory, whether you write them or not: all (every host) and ungrouped (hosts you didn't put in any group). That's why ansible-inventory --graph always starts at @all. Notice the children idea above — branch_pune contains no hosts directly, only the webservers and dbservers groups. Nested groups let you say "all of Pune" in one word.
Now the trick that saves your fingers. When hosts follow a pattern — sw01 through sw12, or www01 through www50 — you don't list them one by one. You use a host range. The brackets hold start:end, and you can add a stride and even letters:
[switches] sw[01:12].infosys.local # sw01 .. sw12 (12 hosts, zero-padded) [webfarm] www[01:50:2].example.com # www01, www03, www05 ... (stride = 2) [shards] db-[a:f].infosys.local # db-a, db-b ... db-f (alphabetic)
$ ansible switches -i hosts.ini --list-hosts
hosts (12):
sw01.infosys.local
sw02.infosys.local
... (sw03 .. sw11) ...
sw12.infosys.localWhere do the connection details go? Not jammed into the inventory file as a wall of inline vars — that gets unreadable fast. The tidy place is two directories beside your inventory: group_vars/ and host_vars/. A file named after a group applies to every host in it; a file named after a host applies only to that host. This is exactly where ansible_host, ansible_user and (for network gear) ansible_network_os belong.
# group_vars/switches.yml (applies to every host in @switches) ansible_network_os: cisco.ios.ios # tells network modules: this is IOS ansible_user: svc_netauto ansible_connection: network_cli # host_vars/sw01.infosys.local.yml (just this one box) # ansible_host: 10.20.10.1 # the real IP to connect to
$ ansible-inventory -i hosts.ini --host sw01.infosys.local
{
"ansible_connection": "network_cli",
"ansible_host": "10.20.10.1",
"ansible_network_os": "cisco.ios.ios",
"ansible_user": "svc_netauto"
}A static inventory is your apartment society's gate-pass register — a printed book at the gate listing every flat, who lives there, and which vehicles belong. Groups are the wings (A-wing, B-wing); children is "all of Tower-2"; host_vars is the note beside one flat ("uses lift 3, dog on file"). It works fine while nobody moves. But the day three families shift out and five move in, the book is wrong until a human updates it — and that is precisely the weakness dynamic inventory removes.
Pause & Predict
Predict: you point a play at the group branch_pune from the example, which only contains the child groups webservers and dbservers. Which actual hosts run the play, and does Ansible mind that branch_pune has no hosts of its own? Type your guess.
Sneha at TCS needs sw01 through sw24 in her inventory, but only the even-numbered ones are in production. What is the cleanest way to add ALL 24 as a range?
② Why static goes stale — and what a dynamic inventory does instead
A static file is honest exactly once: the moment you save it. After that, reality drifts. Priya, an L2 engineer at Flipkart, owns an Auto Scaling group that adds EC2 instances when traffic spikes for a sale and kills them when it dies down. By the time she finishes editing hosts.ini, the IDs in it are already wrong — new instances launched, old ones terminated. Her playbook then configures ghost hosts and skips the real ones.
This isn't only cloud. A network team's switch fleet changes as sites open, gear gets RMA'd, and labs spin up and down. The source of truth for "which hosts exist right now" lives in AWS, Azure, GCP or NetBox — never in a text file a human edits. A static inventory is a photocopy of that truth, and photocopies go out of date.
▶ Watch a static inventory rot between Monday and Tuesday
Priya saves a perfect hosts file Monday morning. Follow what reality does to it over the next 24 hours while the file sits unchanged. Press Play for the healthy path, then Break it to see the failure.
A dynamic inventory flips the model. Instead of reading a frozen file, Ansible runs a plugin that asks the source of truth — the AWS API, the Azure API, the NetBox API, DNS — "what hosts exist right now?" every single run. The list is generated fresh each time, so there is nothing to keep editing and nothing to go stale. Priya deletes her cron-job-to-edit-the-file and points Ansible at an aws_ec2.yml config instead.
Symptom: someone writes a script that dumps the cloud into hosts.ini every night, and during the day the file is still wrong — instances launched at 11 a.m. are invisible until tonight's run. You've rebuilt a stale photocopy on a timer. The fix isn't a fresher photocopy; it's a dynamic inventory plugin that queries the source at the moment the playbook runs, so it is correct for this run, not for last midnight.
Pause & Predict
Predict: a teammate says "dynamic inventory means I never write any config — Ansible just magically finds my hosts." Is that right? What do you actually still have to provide? Type your guess.
Karthik at HCL asks: "Why is a stale static inventory a security risk, not just an annoyance?" Best answer?
③ Dynamic inventory plugins — the modern way (aws_ec2, azure_rm, nb_inventory)
There are two ways to do dynamic inventory, and only one is current. The legacy way was an inventory script — you ran a Python file that spat out JSON. The modern way is an inventory plugin, configured by a tiny YAML file. Plugins win because they ship inside collections, are maintained for you, and hand you grouping and caching features out of the box. RHCE and every current guide teach plugins; treat scripts as legacy you only meet in old repos.
The plugin you reach for depends on your source of truth. The four you'll meet most:
The four dynamic inventory plugins you'll actually use
Tap each — same idea (query the live source), different source of truth. The string after the dots is the fully-qualified plugin name you put in your config.
Pulls live EC2 instances from the AWS API. Config file must end in aws_ec2.yml. So: cloud fleets that autoscale stay correct.
Pulls VMs from Azure Resource Manager. Groups by resource group, location, tags. So: Azure estates without hand-editing.
Pulls Compute Engine instances from GCP. Groups by zone, network, labels. So: GCP fleets self-update too.
Pulls devices/VMs from NetBox (the network team's source of truth). Groups by role, site, tag. So: switches/routers stay live.
A plugin config is short. You name the plugin, say where to look, and then — the powerful part — tell it how to auto-build groups. Two features do the heavy lifting. keyed_groups turns a tag into groups: feed it the role tag and it makes role_web, role_db, one per value. compose sets host variables with a Jinja2 expression — most famously ansible_host.
# File MUST end in aws_ec2.yml (or .yaml) or the plugin ignores it
plugin: amazon.aws.aws_ec2
regions:
- ap-south-1 # Mumbai
filters:
instance-state-name: running # ignore stopped/terminated
keyed_groups:
- key: tags.role # tag:role -> role_web, role_db ...
prefix: role
- key: placement.availability_zone
prefix: az
compose:
ansible_host: {{ public_ip_address | default(private_ip_address) }}
groups:
prod: "tags.env == 'prod'" # custom group from a Jinja2 test$ ansible-inventory -i prod.aws_ec2.yml --graph @all: |--@aws_ec2: | |--i-0a1b2c3d (10.0.12.7) |--@role_web: | |--i-0a1b2c3d (10.0.12.7) |--@az_ap_south_1a: |--@prod:
For network teams the same idea points at NetBox instead of a cloud. The nb_inventory plugin reads your NetBox API and groups devices by the role/site/tag fields you already maintain there — so the inventory mirrors your DCIM exactly, with no second list to keep in sync.
# File ends in nb_inventory.yml (e.g. dc.nb_inventory.yml)
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.infosys.local
token: "{{ lookup('env', 'NETBOX_TOKEN') }}" # never hardcode the token
group_by:
- device_roles # -> groups like device_roles_edge_router
- sites # -> groups like sites_mumbai_dc
device_query_filters:
- has_primary_ip: 'true' # skip patch panels / passive gear
compose:
ansible_network_os: platform.slug$ ansible-inventory -i dc.nb_inventory.yml --graph @all: |--@device_roles_edge_router: | |--mum-edge-01 | |--mum-edge-02 |--@sites_mumbai_dc: | |--mum-edge-01 | |--mum-core-01
Symptom: ansible-inventory -i prod.aws_ec2.yml --graph errors with "Could not parse … no inventory plugin succeeded". Two usual causes. (1) Filename: it must end in aws_ec2.yml/.yaml — prod.yml alone won't match. (2) enable_plugins: in ansible.cfg the [inventory] enable_plugins list must include amazon.aws.aws_ec2 — and you must keep the defaults (host_list, script, auto, yaml, ini, toml) in that list, or your static files stop parsing too. Run -vvv to see which plugins were tried and skipped.
Pause & Predict
Predict: in the aws_ec2 config above, keyed_groups uses key: tags.role with prefix: role. If three instances are tagged role=web and two are tagged role=db, exactly which groups appear, and who is in them? Type your guess.
Meera at Wipro wants Ansible to connect to each EC2 box on its public IP when it has one, otherwise its private IP — without changing the inventory hostname. Which plugin feature does that?
④ A real setup — auto-group, cache, verify with --graph, and the gotchas
Let's wire a believable production setup, the way an L2 at Airtel would. Source of truth: NetBox for the network gear, AWS for the cloud edge. Goal: zero hand-maintained host lists, groups that build themselves from role/site/tag, and a fast feedback loop. Three moving parts: the plugin configs (done in section ③), caching so you don't hammer the API, and ansible-inventory to verify.
First, point Ansible at a directory of inventory configs and turn on caching. A directory lets static and dynamic sources coexist — your fixed jump-hosts in a YAML file, your cloud and NetBox via plugins, all merged into one inventory.
[defaults] inventory = ./inventory # a directory: mixes static + dynamic [inventory] # keep the DEFAULTS or your static files stop parsing: enable_plugins = host_list, script, auto, yaml, ini, toml, amazon.aws.aws_ec2, netbox.netbox.nb_inventory cache = true cache_plugin = ansible.builtin.jsonfile cache_connection = /tmp/ansible_inv_cache cache_timeout = 3600 # seconds; re-query the source after 1h
$ ansible-inventory --graph | head -1 @all: # cache cold -> hits AWS+NetBox; warm runs read /tmp/ansible_inv_cache (fast)
Now verify — this is the command you run a hundred times a week. ansible-inventory --graph draws the tree (groups, children, hosts); --list dumps the full JSON with every variable; --host NAME shows one host's resolved vars. If the source changed and you cached, --flush-cache forces a fresh pull.
ram@netauto:~$ ansible-inventory --graph ram@netauto:~$ ansible-inventory --graph --flush-cache # ignore cache, re-query ram@netauto:~$ ansible-inventory --host mum-edge-01 # one host's vars
@all: |--@aws_ec2: | |--i-0a1b2c3d |--@role_web: | |--i-0a1b2c3d |--@device_roles_edge_router: | |--mum-edge-01 | |--mum-edge-02 |--@sites_mumbai_dc: | |--mum-edge-01 |--@ungrouped:
Once the inventory is correct, you target the right slice with the pattern language. This is where union :, intersection & and exclusion ! earn their keep — and the order matters: Ansible processes : first, then &, then !.
# union: web OR db ansible 'webservers:dbservers' --list-hosts # intersection: hosts in BOTH webservers AND prod ansible 'webservers:&prod' --list-hosts # exclusion: all prod EXCEPT the mumbai site ansible-playbook patch.yml --limit 'prod:!sites_mumbai_dc'
# 'webservers:&prod' ->
hosts (2):
i-0a1b2c3d
i-0e9f8a7b
# only the web boxes that are also tagged env=prodArjun at Zomato faces this
Arjun, an L1 on-call, runs his nightly play and it touches ZERO hosts — "skipping: no hosts matched". But ansible-inventory --graph clearly shows the cloud boxes.
A group-name / pattern mismatch. His play targets hosts: webservers, but the live aws_ec2 plugin built the group as role_web (from keyed_groups prefix: role + key tags.role), not webservers. The group he typed simply doesn't exist in the dynamic inventory.
He stops guessing and reads the actual group names the plugin produced, then checks one host's resolved vars to confirm it's in the group he expects.
ansible-inventory --graph (see the real group names) → ansible-inventory --host i-0a1b2c3dPoint the play at the real group: hosts: role_web. (Or add a groups: webservers: "tags.role == 'web'" line to the plugin config so the friendly name also exists.)
Re-run with --limit role_web --list-hosts → the 3 web instances appear; the nightly play now configures them. ansible-inventory --graph shows role_web populated.
Symptom: a play runs on more hosts than you expected. Cause: your AWS plugin's keyed_groups and your NetBox plugin's group_by both produce a group literally called web, so Ansible merges them into one group spanning two clouds. Fix: give each plugin a distinct prefix (e.g. aws_ vs nb_) so the auto-groups can't collide — role_web from AWS, device_roles_web from NetBox stay separate.
Cold habit to build: never run a playbook against a fresh dynamic source without first running ansible-inventory --graph (and --limit YOUR_TARGET --list-hosts). The graph proves the plugin parsed, auth worked, and the groups you're about to target actually exist and contain the right hosts. Thirty seconds of --graph saves you from a play that runs on the wrong boxes — or on a stranger's recycled IP.
One current, real-world note for 2025-2026. From ansible-core 2.19 the templating trust model was inverted (the "Data Tagging" change). Variables that come from inventory plugins and other external sources are no longer trusted as Jinja2 templates by default — a value pulled from a tag or a NetBox field that happens to contain {{ ... }} is treated as a literal string unless explicitly trusted. The takeaway for you as an inventory author: your compose/keyed_groups expressions still work, but you can't rely on attacker-controllable external data being re-templated — which is exactly the security behaviour you want.
A dynamic inventory is Swiggy's live rider map, not a printed roster pinned to the wall. The printed roster (static) is right at 9 a.m. and wrong by noon as riders log on and off. The live map (dynamic) asks the system who is online right now every time an order drops — so it routes to riders who actually exist this second. ansible-inventory --graph is you glancing at that live map before dispatching the order.
An interviewer asks Neha: "You have a dynamic AWS inventory. Before running a destructive play on prod web servers only, what's the single safest verification step?"
🤖 Ask the AI Tutor
Tap any question — instant, scoped to this lesson. No login, no waiting.
Pre-curated from Ansible docs + community Q&A, scoped to this lesson. For a live prod issue, paste your export into chat.techclick.in.
📝 Wrap-up assessment — six more
You've answered 4 inline. Six left. 70% (7 of 10) marks the lesson complete on your profile. Tap Submit all answers at the end.
🧠 In your own words
Type one line: In one line, why does a dynamic inventory never go stale the way a hand-edited hosts file does? Then compare to the expert version.
🗣 Teach a friend
Best way to lock it in — explain it in one line to a teammate. Tap to generate a paste-ready summary.
📖 Glossary
- Static inventory
- A hand-written INI or YAML file of hosts and groups — the list of hosts Ansible manages. Simple, but you must edit it whenever hosts change.
- Dynamic inventory
- A plugin that generates the host list at run time by querying a live source (AWS, Azure, NetBox, DNS). Never goes stale.
- Group / children
- A named bucket of hosts. A group can contain other groups via :children (INI) or children: (YAML) — nested groups.
- Host range
- A pattern like sw[01:12] or www[01:50:2] that expands to many hosts without listing each one.
- host_vars / group_vars
- Directories (next to the inventory) holding per-host and per-group variable files in YAML — the tidy place for vars.
- ansible_host / ansible_user
- Connection variables: the real IP/name to connect to, and the login user. Set per host or group.
- ansible_network_os
- Tells network modules which platform a device runs (e.g. ios, nxos, eos) so the right CLI/API is used.
- Inventory plugin
- Modern code (inside a collection) that reads a config YAML and produces a dynamic inventory. Replaces legacy scripts.
- keyed_groups
- A plugin feature that auto-creates one group per distinct value of a host property (e.g. one group per tag).
- compose
- A plugin feature that sets host variables from Jinja2 expressions, e.g. ansible_host from public-or-private IP.
- enable_plugins
- The ansible.cfg [inventory] list of which inventory plugins are allowed to parse a source. Keep the defaults in it.
- ansible-inventory --graph
- The command that draws the inventory as a tree of groups and hosts — your verify step before any play runs.
📚 Sources
- Ansible Community Documentation — "How to build your inventory" (INI vs YAML, [group:children]/children:, host ranges www[01:50:2] and db-[a:f], default groups all/ungrouped, host_vars/group_vars layout, ansible_host/ansible_user/ansible_connection). docs.ansible.com/projects/ansible/latest/inventory_guide/intro_inventory.html
- Ansible Community Documentation — "Patterns: targeting hosts and groups" (: union, & intersection, ! exclusion; processing order : then & then !; --limit and --limit @retry_hosts.txt). docs.ansible.com/projects/ansible/latest/inventory_guide/intro_patterns.html
- Ansible Community Documentation — amazon.aws.aws_ec2 inventory plugin (filename suffix *.aws_ec2.yml, plugin/regions/filters/hostnames/keyed_groups key+prefix/compose ansible_host=public_ip_address|default(private_ip_address)/groups; enable in ansible.cfg [inventory] enable_plugins). docs.ansible.com/ansible/latest/collections/amazon/aws/aws_ec2_inventory.html
- NetBox Ansible collection — netbox.netbox.nb_inventory (api_endpoint, token, group_by device_roles/sites, query_filters logical-OR, device_query_filters has_primary_ip; Constructable compose/keyed_groups). docs.ansible.com/projects/ansible/latest/collections/netbox/netbox/nb_inventory_inventory.html · netboxlabs.com/blog/how-to-use-netbox-as-a-dynamic-inventory-source-for-the-red-hat-ansible-automation-platform/
- Ansible Community Documentation — Inventory caching with cache plugins (ansible.cfg [inventory] cache=true, cache_plugin=ansible.builtin.jsonfile / community.general.redis, cache_connection, cache_timeout; ansible-inventory --graph --flush-cache). docs.ansible.com/projects/ansible/latest/plugins/cache.html
- Ansible-core 2.19 Porting Guide — Data Tagging / inverted template trust model: variables from inventory plugins and other external sources are no longer trusted as templates by default (must be marked trust_as_template) — a 2025 security-hardening change relevant to inventory authors. docs.ansible.com/projects/ansible/latest/porting_guides/porting_guide_core_2.19.html
- Red Hat — EX294 Red Hat Certified Engineer (RHCE) exam objectives: "Manage inventory variables", "Manage task execution", build static and dynamic inventories on RHEL 9. redhat.com/en/services/training/ex294-red-hat-certified-engineer-rhce-exam-red-hat-enterprise-linux
What's next?
Your inventory now lists the right routers and switches automatically. Next: point a nightly playbook at them, pull every running-config, diff it against last night, and get a Telegram ping the moment something drifts.