Skip to content

2. Inventory

Everything in Ansible starts with a question: which machines? The inventory is the answer, and it is a more interesting object than a list of hostnames.

inventory.ini
[webservers]
web1.example.com
web2.example.com
web3.example.com
[databases]
db1.example.com
db2.example.com
[production:children]
webservers
databases

Three groups, one of which contains the other two. Now:

Terminal window
ansible webservers -m ping
ansible production -m ping
ansible '!databases' -m ping # everything except
ansible 'web*' -m ping # glob

The YAML form is more verbose and better for anything real, because it nests:

inventory.yml
all:
children:
production:
children:
webservers:
hosts:
web1.example.com:
web2.example.com:
web3.example.com:
databases:
hosts:
db1.example.com:
postgres_role: primary
db2.example.com:
postgres_role: replica

The instinct is to think of inventory as a list of hosts. It is more useful to think of it as a set of labels applied to machines, because that is what you write playbooks against.

A host is normally in SEVERAL groups at once:
web1.example.com
├─ webservers (what it does)
├─ production (which environment)
├─ london (where it is)
├─ ubuntu_22 (what it runs)
└─ pci_scope (which rules apply to it)

Each of those groups can carry its own variables, and a host inherits from all of them. That is what makes inventory expressive: you do not write “for web1 use these 40 settings”, you write “production servers use TLS”, “london servers use this NTP pool”, “PCI-scope servers have this audit rule”, and web1 gets all three because of what it is.

Two groups always exist, whether you define them or not:

all every host in the inventory
ungrouped hosts that are in no other group
— a useful thing to alert on

You can put variables directly in the inventory file, and for anything beyond a couple of values you should not. The convention is directories next to the inventory:

inventory/
hosts.yml
group_vars/
all.yml ← every host
webservers.yml
production.yml
production/ ← or a DIRECTORY of files,
vault.yml all merged together
tuning.yml
host_vars/
web1.example.com.yml
db1.example.com.yml

Ansible loads these automatically by name. group_vars/webservers.yml applies to every host in webservers, with no reference to it anywhere.

A group named after the file is not required to exist, which is a common source of silent nothing-happening: group_vars/webserver.yml (singular) next to a group called webservers (plural) loads for nobody and produces no error. Chapter 14 lists this among the failures that waste an afternoon.

The precedence between overlapping groups is chapter 6’s subject, and the one rule to carry now: all is the weakest, a child group beats its parent, and host_vars beats every group.

The host pattern is the first argument to ansible and the hosts: line of a play. It is more capable than most people use.

all or * everything
webservers one group
web1.example.com one host
webservers:databases union (OR)
webservers:&london intersection (AND)
webservers:!web3 exclusion
web*.example.com glob
~web\d+\.example\.com regex (leading tilde)
webservers[0] the first host in the group
webservers[0:2] a slice — the first three

The intersection and slice forms are the useful ones. webservers:&london is “web servers in London, and nothing else”. webservers[0] is how you run something once on a canary before the rest.

And the flag that belongs in your fingers:

Terminal window
ansible-playbook site.yml --limit web1.example.com
ansible-playbook site.yml --limit 'webservers:&london'

--limit narrows whatever the playbook says. It is the difference between “run this play on production” and “run this play on the one host I am currently fixing”, and it is the single most important safety flag in the tool.

Never guess what the inventory resolves to. Three commands:

Terminal window
ansible-inventory --list # everything, as JSON
ansible-inventory --graph # the tree
ansible-inventory --graph --vars # with variables
ansible-inventory --host web1.example.com # one host's full vars
ansible webservers --list-hosts # just: who matches?
$ ansible-inventory --graph
@all:
|--@production:
| |--@webservers:
| | |--web1.example.com
| | |--web2.example.com
| |--@databases:
| | |--db1.example.com
|--@ungrouped:

ansible-inventory --graph before a first run on a new inventory answers the two questions that actually matter — is everything in the group I think, and is anything sitting in ungrouped because of a typo.

Writing hostnames down stops working the moment machines are created and destroyed by something else. Then the inventory becomes a plugin that asks the source of truth.

# inventory/aws_ec2.yml — the filename suffix matters
plugin: amazon.aws.aws_ec2
regions:
- eu-west-1
filters:
tag:Environment: production
instance-state-name: running
keyed_groups:
- key: tags.Role # tag Role=web → group "web"
prefix: ''
separator: ''
- key: placement.availability_zone
prefix: az
hostnames:
- private-ip-address
compose:
ansible_host: private_ip_address

Now ansible webservers means “every running EC2 instance tagged Role=web in production, right now”. Nobody maintains a list, and a machine that was terminated an hour ago is simply not there.

The plugins that matter:

amazon.aws.aws_ec2 EC2 instances + their tags
google.cloud.gcp_compute
azure.azcollection.azure_rm
community.general.proxmox
community.docker.docker_containers
kubernetes.core.k8s pods as hosts
community.general.consul Consul's service catalogue
ansible.builtin.constructed build groups from existing facts

The Consul one is worth noticing, because it closes a loop that appears repeatedly across this collection: the same registry that Prometheus scrapes from and that HAProxy builds its backend list from can also be the thing that tells Ansible which machines exist. One source of truth for “what is running” is worth a great deal, and it is the argument the Consul guide makes from the other end.

keyed_groups is where the value is. Anyone can get a list of instances. The point is turning cloud metadata into the groups your playbooks are written against, so that tagging an instance Role=web is all it takes to make it a web server. Get the tagging convention right and inventory maintenance disappears entirely.

Cache it. Every playbook run otherwise makes API calls to your cloud provider, which is slow and eventually rate-limited.

# ansible.cfg
[inventory]
cache = true
cache_plugin = jsonfile
cache_connection = ~/.ansible/inventory_cache
cache_timeout = 3600

Then --flush-cache when you need the truth immediately.

A directory can be an inventory, and Ansible merges everything inside it:

inventory/
static.yml ← the database servers, by hand
aws_ec2.yml ← the dynamic fleet
group_vars/
all.yml
Terminal window
ansible-playbook -i inventory/ site.yml

This is the shape most real estates end up with: machines that were provisioned by something that knows about them come from a plugin, and the handful of long-lived special ones are written down.

A small set of variables control how Ansible reaches a host rather than what it should look like. They belong in inventory.

web1.example.com:
ansible_host: 10.0.4.12 # actual address; the
# inventory name is a label
ansible_port: 2222
ansible_user: deploy
ansible_become: true
ansible_become_method: sudo
ansible_python_interpreter: /usr/bin/python3.11
ansible_ssh_common_args: '-o ProxyJump=bastion.example.com'

Two of these deserve emphasis.

ansible_host means the inventory name is just a name. You can call a host db-primary and point it at an IP, which makes playbooks readable and survives the address changing.

ansible_ssh_common_args with ProxyJump is how you reach machines through a bastion, and putting it on a group means every host behind that bastion inherits it. This is far cleaner than maintaining a parallel ~/.ssh/config that only works on your laptop.

- hosts: localhost
connection: local
tasks:
- ansible.builtin.debug: msg="hello"

localhost always exists, even in an empty inventory, and defaults to connection: local — no SSH, no Python payload copy, just running the module in place. This is how you write plays that call APIs, create cloud resources, or do work on the control node. Chapter 11 uses it constantly for delegation.

  • Inventory is not a list of hosts; it is a set of labels applied to machines, and playbooks are written against the labels.
  • all and ungrouped always exist. A host sitting in ungrouped is usually a typo worth alerting on.
  • Variables belong in group_vars/ and host_vars/ directories loaded by name — and a filename that matches no group loads for nobody, silently.
  • Patterns support union, intersection (:&), exclusion (:!), globs, regex and slices. webservers[0] is your canary.
  • --limit is the most important safety flag in Ansible. It narrows whatever the playbook says.
  • Run ansible-inventory --graph before trusting a new inventory.
  • Dynamic inventory turns the cloud into the source of truth. The value is in keyed_groups — turning tags into the groups your playbooks already use — and you must cache it or you will rate-limit yourself.
  • A directory can hold static and dynamic sources together, which is what most real estates need.
  • ansible_host makes the inventory name a label, and ansible_ssh_common_args with ProxyJump puts bastion access in the inventory instead of someone’s laptop.
  • localhost always exists and runs locally without SSH.

Next: the connection and execution model — what “agentless” actually costs, why the default is five hosts at a time, and the two settings that make Ansible several times faster.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source