Ansible

Ansible Inventory Management: Static and Dynamic

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.

Original content from computingforgeeks.com - post 165257

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 to
  • ansible_user – SSH username
  • ansible_port – SSH port (defaults to 22)
  • ansible_ssh_private_key_file – path to the SSH private key
  • ansible_become – set to true to enable privilege escalation
  • ansible_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:

  • all contains every host in the inventory, regardless of group membership
  • ungrouped contains hosts that aren’t in any explicit group (only directly under all)

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:

  1. host_vars/web01.yml (highest priority for inventory variables)
  2. group_vars/webservers.yml (child group)
  3. group_vars/production.yml (parent group)
  4. group_vars/all.yml (lowest priority group)
  5. 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/ and host_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, cache01 as aliases with ansible_host for the actual IP. This makes playbook output readable and inventory files scannable
  • One inventory per environment. Keep prod.yaml, staging.yaml, and dev.yaml separate. 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.

Related Articles

Ansible Solve “[WARNING]: sftp transfer mechanism failed” in Ansible Jenkins Running Jenkins Server in Docker Container with Systemd Automation Automated KVM VM Deployment with PXE Boot and Kickstart Automation How Automation Is Changing Airports – And What’s Coming Next

Leave a Comment

Press ESC to close