Ansible needs to know which servers to manage. That’s the inventory: a mapping of hostnames, IPs, groups, and variables that tells Ansible what exists in your infrastructure. Without a well-structured inventory, even the best playbooks are useless because Ansible has no idea where to run them.
This guide covers both static inventories (INI and YAML formats) and dynamic inventories (scripts and plugins), along with variable management through group_vars and host_vars, host patterns, and the debugging commands that save hours when things go sideways. Whether you manage 5 servers or 5,000, the inventory is the foundation everything else builds on.
Current as of April 2026. Verified on Rocky Linux 10.1 and Ubuntu 24.04 LTS with ansible-core 2.16.14
Prerequisites
Before starting, make sure you have the following in place:
- Ansible installed on your control node (Install Ansible on Rocky Linux / Ubuntu)
- SSH access to at least one managed host with key-based authentication configured
- Basic familiarity with YAML syntax (indentation-based data format using colons and dashes for key-value pairs and lists)
- Tested on: ansible-core 2.16.14, Rocky Linux 10.1, Ubuntu 24.04 LTS
INI Format Inventory
The INI format is the original inventory format and still works well for straightforward setups. Hosts go on individual lines, grouped under bracketed section headers.
Create a file called inventory.ini:
vi inventory.ini
Add the following inventory definition:
# Ungrouped hosts (belong to 'all' and 'ungrouped')
10.0.1.40 ansible_user=admin
# Web servers
[webservers]
web01 ansible_host=10.0.1.20 ansible_user=ubuntu http_port=80
web02 ansible_host=10.0.1.21 ansible_user=ubuntu http_port=8080
# Database servers
[dbservers]
db01 ansible_host=10.0.1.30 ansible_user=rocky db_port=5432
db02 ansible_host=10.0.1.31 ansible_user=rocky db_port=5432
# Range example (expands to web01.example.com through web05.example.com)
[webfarm]
web[01:05].example.com ansible_user=ubuntu
# Group variables applied to all members
[webservers:vars]
ansible_ssh_private_key_file=~/.ssh/web_key
max_clients=200
[dbservers:vars]
ansible_ssh_private_key_file=~/.ssh/db_key
max_connections=100
Each line under a group header defines a host. The host alias (like web01) is what Ansible uses internally, while ansible_host specifies the actual IP to connect to. Inline variables after the IP or alias are assigned to that specific host.
Common connection variables you’ll use across inventories:
ansible_host– the IP or FQDN to connect toansible_user– SSH usernameansible_port– SSH port (defaults to 22)ansible_ssh_private_key_file– path to the SSH private keyansible_become– set totrueto enable privilege escalationansible_python_interpreter– path to Python on the remote host
Verify the inventory parses correctly with --list:
ansible-inventory -i inventory.ini --list
The JSON output shows all groups, hosts, and their variables:
{
"_meta": {
"hostvars": {
"web01": {
"ansible_host": "10.0.1.20",
"ansible_user": "ubuntu",
"http_port": "80",
"ansible_ssh_private_key_file": "~/.ssh/web_key",
"max_clients": "200"
},
"db01": {
"ansible_host": "10.0.1.30",
"ansible_user": "rocky",
"db_port": "5432",
"ansible_ssh_private_key_file": "~/.ssh/db_key",
"max_connections": "100"
}
}
},
"webservers": {
"hosts": ["web01", "web02"]
},
"dbservers": {
"hosts": ["db01", "db02"]
}
}
For a cleaner view, use --graph to see the group hierarchy as a tree:
ansible-inventory -i inventory.ini --graph
The tree output makes it easy to spot grouping issues:
@all:
|--@ungrouped:
| |--10.0.1.40
|--@webservers:
| |--web01
| |--web02
|--@dbservers:
| |--db01
| |--db02
|--@webfarm:
| |--web01.example.com
| |--web02.example.com
| |--web03.example.com
| |--web04.example.com
| |--web05.example.com
One thing to note with INI format: all variable values are strings. The number 80 becomes the string "80". YAML format preserves types, which matters when your playbooks expect integers or booleans.
YAML Format Inventory
YAML inventories represent the same data in a structured, hierarchical format. They handle nested groups and typed variables much better than INI. For anything beyond a handful of servers, YAML is the way to go.
Create inventory.yaml:
vi inventory.yaml
Define the same infrastructure in YAML:
all:
hosts:
monitor:
ansible_host: 10.0.1.40
ansible_user: admin
children:
webservers:
hosts:
web01:
ansible_host: 10.0.1.20
ansible_user: ubuntu
http_port: 80
web02:
ansible_host: 10.0.1.21
ansible_user: ubuntu
http_port: 8080
vars:
ansible_ssh_private_key_file: ~/.ssh/web_key
max_clients: 200
dbservers:
hosts:
db01:
ansible_host: 10.0.1.30
ansible_user: rocky
db_port: 5432
db02:
ansible_host: 10.0.1.31
ansible_user: rocky
db_port: 5432
vars:
ansible_ssh_private_key_file: ~/.ssh/db_key
max_connections: 100
The structure follows the pattern: all at the top, then children for groups, hosts for individual machines, and vars for group-level variables. Unlike INI, YAML preserves data types. The integer 80 stays an integer, booleans remain booleans.
Verify the YAML inventory:
ansible-inventory -i inventory.yaml --list
The output includes the full JSON with _meta.hostvars containing merged variables for every host:
{
"_meta": {
"hostvars": {
"web01": {
"ansible_host": "10.0.1.20",
"ansible_user": "ubuntu",
"http_port": 80,
"ansible_ssh_private_key_file": "~/.ssh/web_key",
"max_clients": 200
},
"db01": {
"ansible_host": "10.0.1.30",
"ansible_user": "rocky",
"db_port": 5432,
"ansible_ssh_private_key_file": "~/.ssh/db_key",
"max_connections": 100
},
"monitor": {
"ansible_host": "10.0.1.40",
"ansible_user": "admin"
}
}
},
"webservers": {
"hosts": ["web01", "web02"]
},
"dbservers": {
"hosts": ["db01", "db02"]
}
}
Notice that http_port is now the integer 80, not the string "80" you’d get from INI. This matters in playbooks that use Jinja2 math or comparisons.
Check specific host variables with --host:
ansible-inventory -i inventory.yaml --host web01
This returns only the merged variables for that host:
{
"ansible_host": "10.0.1.20",
"ansible_user": "ubuntu",
"ansible_ssh_private_key_file": "~/.ssh/web_key",
"http_port": 80,
"max_clients": 200
}
When to use INI vs YAML: INI works for small, flat inventories with a dozen hosts and simple groups. Once you need nested groups, typed variables, or complex hierarchies, switch to YAML. Most production environments end up on YAML eventually.
Groups and Children Groups
Grouping hosts by role is just the beginning. Real infrastructures have layers: production vs staging, datacenter locations, application tiers. Ansible handles this with children groups (groups of groups).
In INI format, use the :children suffix:
[webservers]
web01 ansible_host=10.0.1.20
web02 ansible_host=10.0.1.21
[dbservers]
db01 ansible_host=10.0.1.30
db02 ansible_host=10.0.1.31
[production:children]
webservers
dbservers
[staging]
staging01 ansible_host=10.0.1.50
The same structure in YAML uses nested children keys:
all:
children:
production:
children:
webservers:
hosts:
web01:
ansible_host: 10.0.1.20
web02:
ansible_host: 10.0.1.21
dbservers:
hosts:
db01:
ansible_host: 10.0.1.30
db02:
ansible_host: 10.0.1.31
staging:
hosts:
staging01:
ansible_host: 10.0.1.50
Now ansible production -m ping targets all four servers (web01, web02, db01, db02), while ansible webservers -m ping hits only the two web servers. A host can belong to multiple groups simultaneously, and it inherits variables from all of them.
Ansible creates two default groups automatically:
allcontains every host in the inventory, regardless of group membershipungroupedcontains hosts that aren’t in any explicit group (only directly underall)
You don’t define these groups yourself. They exist implicitly.
Host Variables and Group Variables
Inline variables work for quick tests, but they turn into a maintenance nightmare as your inventory grows. The recommended approach is to store variables in dedicated files under group_vars/ and host_vars/ directories.
Directory Structure
Set up the variable directories alongside your inventory file:
mkdir -p group_vars host_vars
The resulting project layout looks like this:
project/
├── inventory.yaml
├── group_vars/
│ ├── all.yml
│ ├── webservers.yml
│ └── dbservers.yml
└── host_vars/
└── web01.yml
Ansible automatically loads variable files that match group names or hostnames. No configuration needed.
Group Variables
Create variables that apply to all web servers:
vi group_vars/webservers.yml
Add the group-specific settings:
http_port: 80
max_clients: 200
document_root: /var/www/html
ssl_enabled: true
Do the same for database servers:
vi group_vars/dbservers.yml
Define database-specific variables:
db_port: 5432
max_connections: 100
shared_buffers: 256MB
data_directory: /var/lib/pgsql/data
Variables in group_vars/all.yml apply to every host in the inventory. This is a good place for DNS servers, NTP servers, or common SSH settings.
vi group_vars/all.yml
Set defaults for all hosts:
ntp_server: 10.0.1.1
dns_servers:
- 10.0.1.2
- 10.0.1.3
ansible_become: true
Host Variables
When a single host needs settings that differ from its group, use host_vars:
vi host_vars/web01.yml
Override or extend variables for this specific host:
nginx_worker_processes: 4
ssl_certificate: /etc/ssl/certs/web01.pem
backup_enabled: true
Confirm that variables from all sources merge correctly:
ansible-inventory -i inventory.yaml --host web01
The output shows variables merged from the inventory file, group_vars/all.yml, group_vars/webservers.yml, and host_vars/web01.yml:
{
"ansible_host": "10.0.1.20",
"ansible_user": "ubuntu",
"ansible_become": true,
"ansible_ssh_private_key_file": "~/.ssh/web_key",
"http_port": 80,
"max_clients": 200,
"document_root": "/var/www/html",
"ssl_enabled": true,
"nginx_worker_processes": 4,
"ssl_certificate": "/etc/ssl/certs/web01.pem",
"backup_enabled": true,
"ntp_server": "10.0.1.1",
"dns_servers": ["10.0.1.2", "10.0.1.3"]
}
All variable sources are merged into a single flat dictionary for the host. This is exactly what your playbooks and templates see at runtime.
Variable Precedence
Ansible has 22 levels of variable precedence (the full list is in the official documentation). For inventory purposes, the order that matters most is:
host_vars/web01.yml(highest priority for inventory variables)group_vars/webservers.yml(child group)group_vars/production.yml(parent group)group_vars/all.yml(lowest priority group)- Inventory file inline variables
The practical takeaway: if you define http_port: 80 in group_vars/webservers.yml and http_port: 8080 in host_vars/web01.yml, web01 gets port 8080. Every other host in the webservers group gets port 80. Child groups override parent groups, and host-level variables override everything at the inventory level.
Use ansible-inventory --host to see the final resolved values for any host. This catches precedence surprises before they hit a playbook run.
Multiple Inventory Sources
Large environments often split inventory across multiple files: one for production, one for staging, separate files per datacenter or team. Ansible supports merging multiple inventory sources.
Pass multiple -i flags to merge inventories at runtime:
ansible all -i prod.yaml -i staging.yaml -m ping
Ansible merges all groups and hosts from both files. Hosts that appear in both get their variables merged (with the last file taking precedence on conflicts).
You can also point to a directory containing inventory files:
ansible all -i inventories/ -m ping
A gotcha worth knowing: during testing, placing .ini files in a directory and using -i dir/ caused them to be silently ignored. Ansible reads files from inventory directories in alphabetical order, but it can skip files based on extension or naming patterns. The explicit -i file1 -i file2 approach is more reliable and makes the merge order obvious.
To set a default inventory in your project, add it to ansible.cfg:
vi ansible.cfg
Set the inventory path under the [defaults] section:
[defaults]
inventory = ./inventories/prod.yaml
With this set, you no longer need to pass -i on every command. Ansible picks up the configured inventory automatically.
Inventory Patterns and Limiting
Targeting specific subsets of your inventory is one of Ansible’s strengths. You don’t always want to hit every server at once, especially in production.
Target a single group:
ansible webservers -i inventory.yaml -m ping
Target the union of two groups (all hosts in either group):
ansible 'webservers:dbservers' -i inventory.yaml -m ping
Target the intersection (hosts that belong to both groups):
ansible 'webservers:&production' -i inventory.yaml -m ping
Exclude a group from the target set:
ansible 'all:!dbservers' -i inventory.yaml -m ping
This pings every host except those in the dbservers group.
Use a regex pattern to match hostnames:
ansible '~web.*' -i inventory.yaml -m ping
The tilde prefix tells Ansible to treat the pattern as a regular expression. This matches web01, web02, and anything else starting with “web”.
The --limit flag narrows the target after group selection:
ansible all -i inventory.yaml -m ping --limit web01
Only web01 gets pinged despite targeting all. This is particularly useful during playbook runs when you want to test against a single host first.
After a failed playbook run, Ansible creates a .retry file listing the hosts that failed. Re-run against only those hosts:
ansible-playbook site.yml --limit @site.retry
The @ prefix tells Ansible to read the host list from a file. This saves time on large fleets where most hosts succeeded and only a few need a retry.
Dynamic Inventory
Static inventory files break down when your infrastructure changes frequently. Cloud instances spin up and down, auto-scaling groups resize, and Kubernetes nodes come and go. Dynamic inventory solves this by querying external sources at runtime.
Writing a Dynamic Inventory Script
A dynamic inventory script is any executable that responds to two arguments: --list (return the full inventory as JSON) and --host <hostname> (return variables for a specific host). Ansible calls these automatically.
Create a Python script called dynamic_inventory.py:
vi dynamic_inventory.py
Add the following script:
#!/usr/bin/env python3
import json
import sys
def get_inventory():
return {
"webservers": {
"hosts": ["web01", "web02"],
"vars": {
"http_port": 80,
"max_clients": 200
}
},
"dbservers": {
"hosts": ["db01"],
"vars": {
"db_port": 5432
}
},
"_meta": {
"hostvars": {
"web01": {
"ansible_host": "10.0.1.20",
"ansible_user": "ubuntu"
},
"web02": {
"ansible_host": "10.0.1.21",
"ansible_user": "ubuntu"
},
"db01": {
"ansible_host": "10.0.1.30",
"ansible_user": "rocky"
}
}
}
}
def get_host(hostname):
inventory = get_inventory()
hostvars = inventory["_meta"]["hostvars"]
return hostvars.get(hostname, {})
if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1] == "--list":
print(json.dumps(get_inventory(), indent=2))
elif len(sys.argv) == 3 and sys.argv[1] == "--host":
print(json.dumps(get_host(sys.argv[2]), indent=2))
else:
print(json.dumps({}, indent=2))
The _meta key with hostvars is an optimization. When present, Ansible doesn’t need to call --host for each individual host because all variables are already included in the --list response.
Make the script executable:
chmod +x dynamic_inventory.py
Test the --list output to confirm valid JSON:
./dynamic_inventory.py --list
The script should return the full inventory structure with all groups, hosts, and the _meta section.
Verify Ansible can parse it with --graph:
ansible-inventory -i dynamic_inventory.py --graph
The graph output should show all groups and their members:
@all:
|--@ungrouped:
|--@webservers:
| |--web01
| |--web02
|--@dbservers:
| |--db01
Run a connectivity test through the dynamic inventory:
ansible all -i dynamic_inventory.py -m ping
If SSH keys and connectivity are set up, all hosts should return pong. In production, your script would query an API (AWS, Azure, a CMDB, Consul) instead of returning hardcoded data.
Built-in Inventory Plugins
Writing custom scripts isn’t necessary for major cloud providers. Ansible ships with inventory plugins for AWS, Azure, GCP, OpenStack, VMware, and more. These plugins use YAML configuration files instead of executable scripts.
List all available inventory plugins:
ansible-doc -t inventory -l
For AWS EC2, install the required collection first:
ansible-galaxy collection install amazon.aws
Then create a plugin configuration file. The filename must end in aws_ec2.yml or aws_ec2.yaml for Ansible to recognize it as an EC2 inventory source:
vi inventory.aws_ec2.yml
Configure the plugin to pull EC2 instances and group them by tags:
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
- us-west-2
filters:
instance-state-name: running
keyed_groups:
- key: tags.Environment
prefix: env
- key: tags.Role
prefix: role
compose:
ansible_host: public_ip_address
The keyed_groups setting creates dynamic groups based on EC2 tags. An instance tagged Environment: production ends up in the group env_production. The compose setting maps the EC2 public IP to ansible_host so Ansible knows where to connect.
Test it the same way as any other inventory source:
ansible-inventory -i inventory.aws_ec2.yml --graph
Other commonly used inventory plugins include azure.azcollection.azure_rm, google.cloud.gcp_compute, community.vmware.vmware_vm_inventory, and openstack.cloud.openstack. Each has its own configuration options documented via ansible-doc -t inventory <plugin_name>.
Inventory Best Practices
After managing inventories across dozens of environments, these practices consistently prevent problems:
- Use YAML over INI for anything beyond a dozen hosts. INI’s flat structure doesn’t scale well with nested groups and typed variables
- Keep inventory separate from playbooks. Store them in different directories or repositories. Playbooks are logic; inventory is data. They change at different rates and for different reasons
- Use
group_vars/andhost_vars/instead of inline variables. Inline variables are fine for one-off tests, but they clutter the inventory file and make auditing difficult - Name hosts meaningfully. Use
web01,db01,cache01as aliases withansible_hostfor the actual IP. This makes playbook output readable and inventory files scannable - One inventory per environment. Keep
prod.yaml,staging.yaml, anddev.yamlseparate. Accidentally running a production playbook against staging (or the reverse) is a career-shortening event - Version control your inventory alongside playbooks. Treat infrastructure data with the same rigor as code. Git blame on an inventory change has saved more than a few troubleshooting sessions
- For cloud infrastructure, use dynamic inventory plugins. Static files go stale the moment someone launches or terminates an instance. Dynamic plugins query the truth at runtime
Debugging Inventory Issues
When Ansible doesn’t see the hosts or variables you expect, these commands pinpoint the problem quickly.
See the complete inventory as Ansible interprets it:
ansible-inventory -i inventory.yaml --list
If a host is missing from the output, it’s not in your inventory (or it’s in a file Ansible isn’t reading).
Check the group tree structure:
ansible-inventory -i inventory.yaml --graph
This reveals group membership issues like a host ending up in ungrouped when you expected it in webservers.
Inspect the final merged variables for a specific host:
ansible-inventory -i inventory.yaml --host web01
This catches variable precedence issues. If http_port shows 8080 instead of 80, some higher-precedence source is overriding your expected value.
For SSH connectivity problems, increase verbosity:
ansible all -i inventory.yaml -m ping -vvv
Triple-v verbosity shows the exact SSH command Ansible constructs, including which user, key, and port it’s using. This is where you’ll spot mismatched usernames or wrong key paths.
YAML Indentation Errors
The most common inventory parsing failure is bad YAML indentation. YAML uses spaces (never tabs), and the indentation level determines the hierarchy.
This is wrong (inconsistent indentation under hosts):
# WRONG - web01 and db01 are at different indent levels
all:
children:
webservers:
hosts:
web01:
ansible_host: 10.0.1.20
dbservers:
hosts:
db01:
ansible_host: 10.0.1.30
This is correct (consistent 2-space indentation throughout):
# CORRECT - consistent indentation
all:
children:
webservers:
hosts:
web01:
ansible_host: 10.0.1.20
dbservers:
hosts:
db01:
ansible_host: 10.0.1.30
The difference is subtle: db01 was indented by 7 spaces instead of 8. Ansible will either error out or silently misparse the structure. Always use a consistent indent (2 spaces is standard) and a text editor that highlights YAML syntax.
Forgotten ansible_host with Aliases
When you use host aliases like web01 instead of raw IPs, Ansible tries to resolve web01 as a hostname via DNS. If it’s not in DNS or /etc/hosts, the connection fails. The fix is simple: always include ansible_host with the actual IP when using aliases.
# This fails if web01 doesn't resolve in DNS
webservers:
hosts:
web01:
ansible_user: ubuntu
# This works because ansible_host provides the IP
webservers:
hosts:
web01:
ansible_host: 10.0.1.20
ansible_user: ubuntu
The ansible all -m ping -vvv output will show UNREACHABLE with a DNS resolution error if ansible_host is missing. Check for this first when a newly added host doesn’t respond.