SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Ansible inventory files explained

Your Ansible inventory decides which host gets which variable: INI and YAML forms, groups, group_vars, host_vars, connection settings and precedence.

What an Ansible inventory file is

An Ansible inventory file is the list of hosts Ansible may connect to, plus the variables that tell it how to reach each host. Ansible loads the inventory before it loads your playbook. Against one server it barely matters. The second server is where it starts to matter, because two hosts need groups, groups carry variables, and variables have precedence rules that are not obvious.

A static file kept in git is the right shape for a fleet you can count. Two formats are accepted, INI and YAML, and Ansible does not care which one you pick. If you have not run a play yet, your first Ansible playbook against a VPS covers the other half of this.

Tell Ansible which inventory file to read

Ansible does not guess. With no configuration it uses /etc/ansible/hosts, a system-wide file that no project should depend on. Keep the inventory in the project directory and name it once in ansible.cfg, next to the playbook.

# ansible.cfg
[defaults]
inventory = ./inventory.yml

Every command also takes -i, as in ansible-inventory -i inventory.yml --graph, and the flag wins over the config file. When Ansible prints this, it read no inventory at all:

[WARNING]: provided hosts list is empty, only localhost is available. Note that
the implicit localhost does not match 'all'

That means the path is wrong, the file is empty, or your ansible.cfg was ignored. Ansible refuses to load ansible.cfg from a world writable directory, because any user on the box could edit it and change where your commands connect. It says so in a warning naming the directory, so a project sitting in /tmp silently loses its inventory setting. Move the project somewhere you own and the warning stops.

The INI form and the YAML form, side by side

Here are the same four servers twice. INI first, because most examples you will find online are INI.

[web]
web1 ansible_host=203.0.113.10
web2 ansible_host=203.0.113.11

[db]
db1 ansible_host=203.0.113.20

[build]
build1 ansible_host=203.0.113.30 ansible_port=2222

[prod:children]
web
db

[all:vars]
ansible_user=deploy

Now the same fleet in YAML.

all:
  vars:
    ansible_user: deploy
  children:
    prod:
      children:
        web:
          hosts:
            web1:
              ansible_host: 203.0.113.10
            web2:
              ansible_host: 203.0.113.11
        db:
          hosts:
            db1:
              ansible_host: 203.0.113.20
    build:
      hosts:
        build1:
          ansible_host: 203.0.113.30
          ansible_port: 2222

A group in the YAML form accepts hosts, children and vars, and nothing else. A misspelled key is skipped with a warning rather than an error, so writing var: instead of vars: loses every variable in that block and the run continues. The warning names the key and the group it was skipped in. Read it.

Note the trailing colon after each host name. web1: with nothing under it is a host with no host variables, which is normal and correct. Write the hosts as a YAML list instead and the parser stops:

ERROR! Invalid "hosts" entry for "web" group, requires a dictionary, found "<class 'ansible.parsing.yaml.objects.AnsibleSequence'>" instead.

Name a YAML inventory with a .yml, .yaml or .json extension, or with no extension at all. Those are the only names the YAML inventory plugin claims. Call the file hosts.txt and the YAML plugin declines it, the INI parser takes it instead, and the error you get complains about your indentation rather than about the file name.

Pick YAML for anything past a bare host list. An INI value is a single line of text, so a list or a dictionary cannot be written there. YAML keeps the type you wrote.

Groups, nested groups, and the two you always have

Two groups exist whether you write them or not. all holds every host. ungrouped holds every host that belongs to no other group. Setting [all:vars] in INI, or vars: under all: in YAML, sets a default for the whole fleet.

A host may sit in as many groups as you like, and that is the point of groups. web1 can be in web for what it runs and in prod for where it runs. Then group_vars/web.yml carries the nginx settings and group_vars/prod.yml carries the environment settings, and neither file needs to know the other exists.

A parent group is a group of groups. In INI it is the :children suffix. In YAML it is a children: key. Parents give you one name to aim at: ansible prod -m ping reaches every host under prod by way of web and db. A parent lists no hosts of its own, and its members come from its children.

Group names take letters, digits and underscores. A hyphen or a dot in a group name is not valid, and Ansible warns instead of stopping:

[WARNING]: Invalid characters were found in group names but not replaced, use -vvvv to see details

Write web_staging, not web-staging. The group still works today, so this is worth fixing while it is cheap.

group_vars and host_vars: where variables live

Inline key=value on a host line works and does not scale. Move variables into files named after the group or the host. Ansible loads them by name, with no reference from the inventory file.

  • group_vars/all.yml for defaults every host shares.
  • group_vars/web.yml for the web group.
  • group_vars/prod.yml for the prod parent group.
  • host_vars/web1.yml for the single host web1.

The file name has to match the name in the inventory exactly. group_vars/webservers.yml does nothing at all for a group called web. Nothing errors, because Ansible has no way to know you meant that file to apply, so the variable is simply absent when the play runs. The play then fails on an undefined variable, or quietly uses a default and configures the wrong thing.

Each of these can be a directory instead of a file. group_vars/web/ holding nginx.yml and firewall.yml loads both, which keeps a large group readable.

Ansible looks for group_vars and host_vars in two places: next to the inventory file, and next to the playbook. Both are loaded. When the same variable appears in both, the playbook side wins. Keeping only one of these two directories in your project saves you the most common half hour of confusion in this whole topic.

Secrets do not belong in group_vars/prod.yml. It is a plain text file in git, and a database password in git is a password you have handed to everyone with clone access. Encrypt the values, or the whole file, with Ansible Vault for encrypted secrets and keep the inventory itself free of anything private.

The connection variables that matter on a rented VPS

An inventory hostname is a label. Ansible uses it as a name only, unless that name also resolves in DNS (domain name system). A fresh VPS gives you an IP address and no name, so you set ansible_host and keep the short label for yourself.

  • ansible_host is the address or resolvable name Ansible dials. This one is per host.
  • ansible_user is the login account. Usually per group.
  • ansible_port is the SSH port when it is not 22.
  • ansible_ssh_private_key_file points at the key this fleet uses.
  • ansible_become set to true makes tasks escalate with sudo by default.
  • ansible_python_interpreter pins the Python path on the target when Ansible's guess is wrong.

Without ansible_host, a label that is not in DNS fails at the SSH layer rather than inside Ansible:

web1 | UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ssh: Could not resolve hostname web1: Name or service not known", "unreachable": true}

ansible_port is the one people forget after hardening a server. Ansible cannot detect your change, so it keeps using 22 and the connection is refused on the port nothing is listening on:

ssh: connect to host 203.0.113.30 port 22: Connection refused

Set ansible_port=2222 on the host or on the group, in the same place a human would write it down. Changing the SSH port with SELinux and firewalld covers the server side, and the inventory has to agree with whatever you did there.

A brand new server is not in ~/.ssh/known_hosts on your control node, so the first connection fails with Host key verification failed. inside an UNREACHABLE! result. Add the key on purpose with ssh-keyscan -H 203.0.113.10 >> ~/.ssh/known_hosts after checking the fingerprint against your provider's console. Turning host_key_checking off in ansible.cfg also makes the error go away, and it makes every future connection accept whatever key answers, which is the thing host keys exist to prevent.

Privilege escalation is become. Set ansible_become: true in group_vars when the account you log in as always needs sudo, or set become: true on a single play or task when only part of the work needs root. If sudo asks for a password on the target, Ansible cannot invent one:

fatal: [web1]: FAILED! => {"msg": "Missing sudo password"}

Pass --ask-become-pass, short form -K, for that run. ansible_become_user escalates to an account other than root. Two variables are worth refusing outright: ansible_password and ansible_ssh_pass put a login password into an inventory file. Use keys, and see the basics of SSH key management for keeping the private half in one place.

You can also keep the connection details in ~/.ssh/config on the control node. Ansible's default connection runs your OpenSSH client, so a Host web1 block with HostName, Port and User works with no ansible_* variables at all. The inventory wins when both are set, which is what you want on a team, because the inventory is in git and one person's ~/.ssh/config is not.

Prove the inventory before you run a playbook

ansible-inventory reads exactly the file the playbook will read, and it changes nothing.

ansible-inventory --graph
ansible-inventory --graph --vars
ansible-inventory --list
ansible-inventory --host web1

--graph prints the group tree with the hosts under it. Read your own output. Check that every host appears where you meant it, and that nothing has fallen into ungrouped. --graph --vars adds the variables each group and host resolved to, which is the fastest answer to "was my file loaded at all". --host web1 prints the finished variable set for one host, after precedence has been applied. A variable missing from that output means the file name is wrong or the directory is in the wrong place.

Then prove Ansible can log in. The ping module is not ICMP ping: it opens an SSH connection, runs Python on the target and returns pong. One command therefore tests the address, the user, the port, the key and the Python interpreter.

ansible web -m ping
ansible prod --list-hosts
ansible web -m debug -a "var=ansible_port"

Run ping against one host before a group, and against a group before a playbook. --list-hosts resolves a pattern without connecting to anything, so it is the cheap way to confirm a pattern means what you think. The debug call prints the value that won for each host, which ends a precedence argument in one line.

Patterns are how you aim. ansible web:db is the union of both groups. ansible 'prod:!web' is everything in prod except the web hosts. Wrap any pattern containing ! in single quotes, or your shell will try to expand it first.

For playbooks, --limit narrows a run without editing anything.

ansible-playbook site.yml --limit web --list-hosts
ansible-playbook site.yml --limit web1 --check --diff

A typo in the limit gives you a warning and then a hard stop, rather than a run that silently does nothing:

[WARNING]: Could not match supplied host pattern, ignoring: wev
ERROR! Specified hosts and/or --limit does not match any hosts

--check runs the play without changing the target, and --diff shows the file edits it would make. Neither is a full simulation, because a task whose input comes from an earlier task cannot be predicted, so treat a check run as a strong hint. Once the inventory is right and the playbook starts to grow, moving a playbook into a role is the next step.

Why did my variable not apply?

Precedence. Ansible has 22 levels of it, and the inventory occupies the bottom few, so anything defined closer to the task wins. These rules explain almost every real case.

  • A host variable beats a group variable. Always, whichever group it was.
  • A child group beats its parent, because it is more specific. web beats prod.
  • group_vars/all is the weakest group file. Treat it as a default, not as policy.
  • Among groups at the same level, Ansible merges in alphabetical order by group name and the last merge wins. web beats apache because of the letter it starts with.
  • A group_vars directory next to the playbook beats one next to the inventory.
  • Play vars, role vars and task vars all beat everything in the inventory.
  • -e on the command line beats all of it, host variables included.

The alphabetical rule is the one that surprises people. If web1 is in both apache and web, and both set http_port, the value from web lands. To fix the order without renaming a group, set ansible_group_priority on the group that must win. It defaults to 1, and a larger number merges later, so a larger number wins. Set it in the inventory file itself, not in a group_vars file, because Ansible reads it while deciding how to load those files.

apache:
  vars:
    ansible_group_priority: 10

One more source of surprise: a dictionary variable is replaced whole, not merged key by key. If two files each set part of one nginx dictionary, the loser disappears entirely. Define nginx_workers and nginx_keepalive as separate variables instead, or merge on purpose in the play with the combine filter.

When you are unsure, stop reasoning and ask the tool. ansible-inventory --host web1 prints the resolved set, and ansible web1 -m debug -a "var=http_port" prints what the play itself will see.

Dynamic inventory, and when you actually need it

A dynamic inventory is a plugin that builds the host list by asking an API, so a cloud provider's tags become your groups. It earns its complexity when machines are created and destroyed faster than a person can edit a file. For a fleet you can count, a static file is better: it is reviewable in a pull request, and it still lists your servers when the provider's API is down. When the file starts to feel unwieldy, the fix is usually better group structure rather than a plugin. Running one control node against many Linux servers covers the workflow that grows around it.

FAQ

Should I write my Ansible inventory in INI or YAML?

Both work and Ansible reads either. INI is shorter for a flat host list. YAML is the right choice as soon as variables carry structure, because an INI value is a single line of text and cannot hold a list or a dictionary, and because nested groups read more clearly through the children: key than through the :children suffix. If you choose YAML, give the file a .yml, .yaml or .json extension, or no extension at all. Those are the only names the YAML inventory plugin claims, and any other extension gets handed to the INI parser.

Where should variables go, group_vars or host_vars?

Put a value in group_vars/<group>.yml when more than one host shares it, and in host_vars/<host>.yml only when it is genuinely unique to one machine. ansible_host is the clearest host variable, since every server has its own address. ansible_user and ansible_port usually belong to a group. The file name must match the group or host name in the inventory exactly, because a mismatch produces no error at all, just a missing variable. Keep passwords and keys out of both and encrypt them with Ansible Vault.

Why did my group variable not apply to the host?

Something more specific overrode it, or the file was never loaded. In rough order of likelihood: a host variable for that host beats every group variable; a child group beat its parent; two groups at the same level both set it and the alphabetically later group won; two group_vars directories exist, one beside the inventory and one beside the playbook, and the playbook one won; or -e on the command line beat everything. Run ansible-inventory --host <name> to see the value that survived, and ansible-inventory --graph --vars to see which group contributed it.

How do I tell Ansible about a VPS on a non-standard SSH port?

Set ansible_port on the host or its group: ansible_port=2222 after the host name in INI, or ansible_port: 2222 under the host in YAML. Ansible cannot detect a port change, so until you set it every connection goes to port 22 and fails with ssh: connect to host <address> port 22: Connection refused. Confirm the fix with ansible <host> -m ping before you run a playbook. A Port line in ~/.ssh/config also works, but that file lives on one laptop while the inventory lives in git.

Do I need a dynamic inventory?

Not for a fleet you can count. A dynamic inventory plugin queries a provider API to build the host list, which matters when instances come and go without a person involved. A static file is easier to review, and it still describes your servers when that API is unavailable. Start static and keep the group structure clean. Move to a plugin only when hand editing has become the bottleneck.