CoursesAnsibleInstall, inventory & ad-hoc commands

Install, inventory & ad-hoc commands

Hosts, groups, and one-off tasks.

Beginner12 min · lesson 2 of 12

A site foreman carries two things: a crew list and a radio. The crew list says who is on the job today and which trade each person belongs to. Roofers here, electricians there. The radio lets the foreman hold one button and say one sentence to a whole trade at once. No meeting, no paperwork. Ansible hands you both objects. The inventory is the crew list. The ad-hoc command is the radio. By the end of this lesson you will have installed Ansible, written down a fleet, and run one action across a group of machines over SSH (Secure Shell, the encrypted remote-login protocol). You will also know how to check who you are about to shout at before you press the button, which is the part that keeps you employed.

Install the control node

Ansible runs from one machine. That machine is called the control node, and it is the only place anything gets installed. Your laptop counts. A small hardened box inside the management network also counts, and on a team that is usually the right answer. Everything else is a managed node, and managed nodes get nothing: no agent, no daemon (a program that sits in the background waiting for work), no extra listening port. The control node opens an SSH session, copies a small Python program across, runs it, reads the answer, deletes the program, and hangs up. If you can already ssh into a host and it has a Python interpreter, Ansible can drive it.

Install it with pipx, a tool that parks a Python application in its own private virtual environment (an isolated folder with its own copy of every library) so upgrading Ansible cannot break your operating system's Python, or the other way round. ansible-core 2.16 wants Python 3.10 or newer on the control node. The managed hosts are far more relaxed. Python 3.6 or newer is plenty, and 2.16 happens to be the last release that will still talk to a Python 2.7 target, so whatever Python 3 the distribution already ships is fine.

terminal
# pipx keeps Ansible off your system Python; --include-deps exposes the
# ansible-core commands (ansible-playbook, ansible-vault, ansible-inventory...)
$ pipx install --include-deps ansible
# pipx lists the commands it installed. This is the line that matters:
$ ansible --version
output
ansible [core 2.16.5]
config file = None
configured module search path = ['/home/deploy/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /home/deploy/.local/share/pipx/venvs/ansible/lib/python3.11/site-packages/ansible
ansible collection location = /home/deploy/.ansible/collections:/usr/share/ansible/collections
executable location = /home/deploy/.local/bin/ansible
python version = 3.11.9 (main, Apr 10 2024, 00:00:00) [GCC 13.2.0] (/home/deploy/.local/share/pipx/venvs/ansible/bin/python)
jinja version = 3.1.4
libyaml = True

Two different version numbers hide in that install. The package called ansible is the batteries-included distribution, 9.x here, and it bundles the engine plus a few thousand modules from community collections. ansible-core 2.16.5 is the engine itself: the command-line tools and the ansible.builtin collection that ships inside them. Names matter as much as versions. A nickname is fine at a party; payroll wants your full legal name. So write modules out in full, ansible.builtin.copy rather than copy. The long form is the FQCN (fully qualified collection name), and it says exactly whose code is about to run as root on your servers. The short name is not a harmless abbreviation either. It resolves through a compatibility layer called ansible.legacy, which searches your project's own library/ folder before it falls back to the built-in module, so a file named copy.py sitting next to your work quietly becomes copy for everyone who runs that project. Handy on purpose, ugly by accident. The line to notice in the output above, though, is config file = None. Nothing is overriding the defaults yet. We fix that at the end, because that one file decides your default inventory and whether Ansible checks host keys.

The inventory is your blast radius

An inventory is a text file listing the machines Ansible is allowed to touch, sorted into groups. The groups are the point. Nobody remembers that 10.0.0.5 is the database; everybody remembers db. A host can belong to as many groups as you like, so one machine can be a web server, a canary (the single host you change first, so a bad change hurts one box instead of twenty), and part of production all at once. Two groups come free in every inventory: all, meaning every host, and ungrouped, for hosts you listed without a group. Ranges save typing, so web[02:03] expands into web02 and web03. The file below is written in INI style, the square-bracket-heading layout that Windows .ini files and Git's own config use, and it stays the fastest thing to read at a glance.

inventory.ini
# inventory.ini
[web]
web01.example.com
web[02:03].example.com # expands to web02 and web03
[db]
db1 ansible_host=10.0.0.5 ansible_user=deploy
# The one host we always change first
[canary]
web01.example.com
# Variables for every member of [web]
[web:vars]
ansible_user=ubuntu
ansible_port=22
# A group built out of other groups, not out of hosts
[production:children]
web
db

Read that file as three kinds of line. A bare hostname is a host. A hostname followed by key=value pairs sets variables for that host alone: ansible_host is the address Ansible actually dials, which means the inventory name can be a friendly label that DNS (the Domain Name System, the internet's address book for turning names into numbers) has never heard of, and ansible_user is the account it logs in as. A [group:vars] section hands the same variables to every member of the group. A [group:children] section builds a group out of other groups, so production is a box holding web and db rather than a list of machines. Keep group names to letters, numbers and underscores. A hyphen still parses, but Ansible warns about invalid characters in group names on every single run, because a dash is not legal inside a variable name and that group name turns into one later.

Now stop trusting your own reading of the file and ask Ansible what it parsed.

terminal
$ ansible-inventory -i inventory.ini --graph
output
@all:
|--@ungrouped:
|--@canary:
| |--web01.example.com
|--@production:
| |--@web:
| | |--web01.example.com
| | |--web02.example.com
| | |--web03.example.com
| |--@db:
| | |--db1

web01 appears twice because membership overlaps, which is normal; one host object shows up under every group that claims it. Notice that web and db no longer hang off all directly. Once a group has a parent, it moves underneath it. If a group you expected is missing, or one you expected to be full comes back empty, you have found a typo before it cost you anything. Two more flags earn their keep here. Adding --vars prints each host's variables underneath its name. And --host asks the question that actually matters: after all that inheritance, what did one specific machine end up with?

terminal
$ ansible-inventory -i inventory.ini --host web02.example.com
output
{
"ansible_port": 22,
"ansible_user": "ubuntu"
}

web02 has no variables of its own. Both of those came down from the [web:vars] block, and that one command is the whole inheritance model made visible. It is also how you catch an inventory that logs into production as root when you were certain it used deploy. One habit worth stealing while you are here: keep production and staging in separate inventory files rather than as two groups inside one file. A group is one mistyped pattern away from being swept up by accident. A file you never passed with -i cannot be touched at all. The honest cost is duplication, because shared variables now live in two places and will drift apart eventually.

What one ad-hoc command actually does

An ad-hoc command runs one module against one pattern of hosts, right now, with nothing written down anywhere. The shape never changes: ansible <pattern> -m <module> -a "<arguments>". It is the right tool for questions and small one-off repairs. Is the fleet awake, which kernel is everyone running, restart that one stuck service. It is the wrong tool for anything you will need to do twice or explain to an auditor later, which is what playbooks are for.

One ad-hoc command, start to finish
1pattern
'web:!canary' typed on the command line
2inventory
pattern resolves to real hostnames
3SSH out
5 hosts at a time by default (forks)
4module runs
copied to a temp dir, run, removed
5JSON returns
SUCCESS, CHANGED, FAILED or UNREACHABLE
Nothing is installed on the target. The module is pushed for that single run, executed by the host's own Python, then deleted.

The first flag to learn is the one that does nothing at all. --list-hosts resolves your pattern against the inventory and prints the machines it landed on, without opening a single connection. Reading the address labels before you post the letter costs one second.

terminal
$ ansible web -i inventory.ini --list-hosts
output
hosts (3):
web01.example.com
web02.example.com
web03.example.com

Now knock on the doors. The ansible.builtin.ping module is badly named: it sends no ICMP (Internet Control Message Protocol, what the ping command on your laptop uses) packets whatsoever. It logs in over SSH, copies a tiny Python module across, runs it, and waits for the word pong to come back as JSON (JavaScript Object Notation, a plain-text way of writing structured data). A pong therefore proves four separate things in one shot: the name resolved, the SSH port answered, your key authenticated, and there is a working Python on the far side. That is a genuine end-to-end check, which is why it is the first thing anyone runs against a new host.

terminal
$ ansible all -i inventory.ini -m ansible.builtin.ping
output
web01.example.com | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
web03.example.com | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
web02.example.com | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
db1 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: ssh: connect to host 10.0.0.5 port 22: Connection timed out",
"unreachable": true
}

Three details in that output pay rent. First, web03 printed before web02. Hosts run in parallel, five at a time by default, and results arrive in finishing order rather than inventory order. That five is the fork count, meaning how many hosts Ansible works on at the same moment, and you raise it with -f 20 once the inventory grows. Second, UNREACHABLE and FAILED are different animals. UNREACHABLE means no module ever ran, so you are debugging network, credentials or host keys. FAILED means the module ran perfectly well and told you no, so you are debugging your request. Third, discovered_interpreter_python is Ansible telling you which Python it chose, which is worth a glance on a host that has three of them. The command exits non-zero when any host failed or was unreachable, so a shell script or a CI (continuous integration, the robot that runs your pipeline) job wrapping it can actually notice the difference.

Turning off host key checking hands the fleet to whoever answers
A brand-new host fails its first run with UNREACHABLE and "Host key verification failed", and the internet's favourite fix is host_key_checking = False, or the ANSIBLE_HOST_KEY_CHECKING environment variable, which outranks whatever your reviewed config file says. What that setting really does is tell SSH to accept whatever key answers at that address, every time, forever. Anything sitting on the network path, squatting on a recycled cloud IP (Internet Protocol) address, or answering a poisoned DNS record now receives your login, your sudo password prompt, and the module payload you were about to run as root. Add the keys deliberately instead. Run ssh-keyscan against the host and compare the fingerprint with what the machine printed on first boot or what your cloud console shows, distribute them with the ansible.builtin.known_hosts module, or run an SSH certificate authority (one signing key that vouches for every host key, the same trick browsers use for websites) so new machines are trusted by signature. Leave the checking on.

Patterns are how you aim

A pattern is a small language, not a list of names. all (or *) hits everything. A group name hits that group. A colon means or, so web:db is both groups together, and a comma does exactly the same job. The colon is what you will see in older examples everywhere; switch to commas the day an IPv6 address, which is nothing but colons, lands in your inventory. An ampersand means and, so 'web:&canary' is only the machines in both groups, which is how you roll a change to one host before the other twenty. A leading exclamation mark subtracts, so 'web:!canary' is the rest of the fleet once the canary has proved fine. A leading tilde treats the rest as a regular expression. Wrap patterns in single quotes, always, because your shell will happily eat the * and the ! before Ansible ever sees them.

terminal
# Single quotes matter: bash reads ! as history expansion and * as a glob
$ ansible 'web:!canary' -i inventory.ini --list-hosts
output
hosts (2):
web02.example.com
web03.example.com

The --limit flag does the same narrowing from the other side. It trims whichever pattern the command already carries, which makes it the standard safety belt on a shared playbook you did not write. Get a pattern wrong, though, and what happens next depends entirely on how wrong you got it.

terminal
$ ansible wbe -i inventory.ini -m ansible.builtin.ping
output
[WARNING]: Could not match supplied host pattern, ignoring: wbe
ERROR! Specified hosts and/or --limit does not match any hosts

A pattern that matches nothing stops with that error and a non-zero exit code. That is the friendly failure. The dangerous one is a pattern that matches something. Ask for 'web,dbb' and Ansible prints the same yellow warning about dbb, then cheerfully runs against the three web hosts and exits zero. Your change reached part of the fleet, your script called it a success, and the database never got patched. That is the most common way an Ansible run lies to you, and --list-hosts is the whole defence.

One module, one action

Leave -m off entirely and you get ansible.builtin.command, which runs one binary with arguments and nothing more. No pipes, no redirects, no environment variable expansion, no ~ and no *. That restriction is deliberate. There is no shell in the picture, so there is nothing for a stray semicolon inside a variable to break out into.

terminal
$ ansible web -i inventory.ini -m ansible.builtin.command -a 'uptime'
output
web01.example.com | CHANGED | rc=0 >>
14:22:07 up 12 days, 3:41, 1 user, load average: 0.08, 0.11, 0.09
web02.example.com | CHANGED | rc=0 >>
14:22:07 up 12 days, 3:40, 0 users, load average: 0.00, 0.02, 0.00
web03.example.com | CHANGED | rc=0 >>
14:22:08 up 4 days, 22:11, 1 user, load average: 0.15, 0.09, 0.06

CHANGED, in yellow, for a command that only read something. The command module has no idea whether uptime altered the machine, so it assumes the worst and reports a change on every single run. That is why command and shell are the enemies of idempotency (running the same thing twice and having the second run do nothing), and why the next lesson is about modules that genuinely model state. The rc=0 is the return code, the number a Unix program hands back as it exits; anything non-zero and Ansible marks that host failed. Add -o if you want one line per host instead of this block form, which is far easier to grep across fifty machines.

The command module quietly drops your pipes
Run ansible web -m ansible.builtin.command -a 'echo ok > /tmp/health' and you get rc=0, a cheerful CHANGED, and no file. The command module handed the > to echo as a literal argument, so each host printed the text "ok > /tmp/health" to standard output and wrote nothing anywhere. Same story for |, &&, $HOME, ~ and *. Switch to ansible.builtin.shell when you truly need a shell to interpret the line, and know what you bought: shell feeds the whole string to /bin/sh, so anything you paste or template into -a becomes executable code on every matched host. Never build a shell argument out of a hostname, a ticket title, a filename from a bucket, or anything else a stranger could have influenced. When both modules would work, pick command.
terminal
$ ansible web -i inventory.ini -m ansible.builtin.shell -a 'df -h / | tail -n1'
output
web01.example.com | CHANGED | rc=0 >>
/dev/nvme0n1p1 40G 12G 26G 32% /
web02.example.com | CHANGED | rc=0 >>
/dev/nvme0n1p1 40G 9.4G 29G 25% /
web03.example.com | CHANGED | rc=0 >>
/dev/nvme0n1p1 40G 38G 1.4G 97% /

There is web03 at 97 percent, thirty seconds after you thought to ask. Most repairs then need root, and Ansible calls that become: you log in as your ordinary unprivileged user and the task is escalated on the far side, through sudo (superuser do) by default. The flag is -b. Add -K and Ansible asks you once for the sudo password and reuses it for the whole run; forget it on a host whose sudo demands a password and every one of them comes back FAILED with "Missing sudo password". Use --become-user when the target account is something other than root. For services, reach for ansible.builtin.systemd_service, which is the current name for the module that used to be called systemd (that old name still works as an alias) and which talks straight to systemd, the program that starts, stops and supervises services on nearly every modern Linux box. Its cousin ansible.builtin.service is the portable wrapper that guesses your init system for you, which is useful on a mixed fleet and vague on a modern one. And db1 answers now, by the way; the firewall rule that ate that first ping is gone.

terminal
$ ansible db -i inventory.ini -b -m ansible.builtin.systemd_service \
-a 'name=postgresql state=restarted'
output
db1 | CHANGED => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.11"
},
"changed": true,
"name": "postgresql",
"state": "started",
"status": {
"ActiveState": "active",
"LoadState": "loaded",
"SubState": "running",
"UnitFileState": "enabled"
}
}

This time changed: true means something really happened, because the module understands what restarting is. The status block is systemd's own answer to systemctl show, taken at the moment Ansible inspected the unit and cut down to four lines here; the real one carries a couple of hundred properties. UnitFileState: enabled is the line to read, because it tells you the unit will come back on its own after a reboot. And here is where the security story lands. An ad-hoc command with -b is a root shell on every host your pattern matched, driven from a laptop, with no review, no ticket and by default no record anywhere at all. Whoever gets your control node gets that same radio. The least you can do is make the runs leave a trail.

Make it repeatable, and leave a trail

ansible.cfg is the house rules pinned up by the door. Put it next to your inventory, inside the repository, where a reviewer will see it. Ansible looks in four places and takes the first one it finds, with no merging at all: the ANSIBLE_CONFIG environment variable, then ansible.cfg in the current directory, then ~/.ansible.cfg, then /etc/ansible/ansible.cfg. Two consequences are worth burning in. An environment variable exported in somebody's shell profile silently outranks the file your team agreed on. And Ansible refuses to read an ansible.cfg out of a current directory that is world-writable, printing a warning and ignoring the file, because anyone able to drop a file in /tmp could otherwise hand you a different inventory and a different set of privilege rules. If you want a starting point, ansible-config init --disabled writes out every setting, commented off.

ansible.cfg
[defaults]
inventory = ./inventory.ini
host_key_checking = True
interpreter_python = auto_silent
forks = 20
log_path = ./ansible.log

host_key_checking = True is already the default, so writing it down changes no behaviour. It changes the review. A future quick fix now has to delete a line somebody deliberately wrote, rather than add a quiet one nobody notices. interpreter_python = auto_silent keeps the automatic Python discovery and stops it commenting on the choice. forks = 20 raises the parallelism from the default of 5, which starts to matter around the fiftieth host. The inventory line means you can drop -i from every command from now on. And log_path is the setting everyone skips, which is the one that turns an untraceable radio call into a record.

terminal
$ ansible-config dump --only-changed
output
CONFIG_FILE() = /home/deploy/fleet/ansible.cfg
DEFAULT_FORKS(/home/deploy/fleet/ansible.cfg) = 20
DEFAULT_HOST_LIST(/home/deploy/fleet/ansible.cfg) = ['/home/deploy/fleet/inventory.ini']
DEFAULT_LOG_PATH(/home/deploy/fleet/ansible.cfg) = /home/deploy/fleet/ansible.log
HOST_KEY_CHECKING(/home/deploy/fleet/ansible.cfg) = True
INTERPRETER_PYTHON(/home/deploy/fleet/ansible.cfg) = auto_silent

Read the brackets, not the values. --only-changed does not mean "different from the default value", it means "came from somewhere other than the built-in default", and the bracket tells you where. That is why HOST_KEY_CHECKING is on the list even though True is what it would have been anyway: the source changed, the value did not. Which is the fastest answer to "why does this behave differently on my machine than on yours". And if one of those brackets ever reads env: ANSIBLE_HOST_KEY_CHECKING instead of a file path, you have found somebody's shell profile steering your fleet from outside code review.

ansible.log
2026-07-21 14:22:07,884 p=48213 u=deploy n=ansible | web01.example.com | CHANGED | rc=0 >>
14:22:07 up 12 days, 3:41, 1 user, load average: 0.08, 0.11, 0.09
2026-07-21 14:24:19,207 p=48566 u=deploy n=ansible | db1 | CHANGED => {
"changed": true,
"name": "postgresql",
"state": "started",
"status": {
"ActiveState": "active",
"UnitFileState": "enabled"
}
}

Those are two entries from mine, with the systemd property dump on the second one cut down so it fits on the page. Every ad-hoc run now appends to that file, host by host, with the process id and the user who ran it. And that file is the honest trade-off of this whole lesson. It is a plain-text record of every command you fired and every answer you got, which is what you want when someone asks who restarted the database at 14:24. It is also a plain-text record of every command you fired and every answer you got, which is what you do not want world-readable, because module output routinely carries configuration, connection strings and the contents of files you dumped. Ansible creates it with your umask (the default permission mask a shell stamps on new files), which on most systems means mode 0644 and every account on that box can read it. Tighten it, rotate it, and never point log_path at a shared temporary directory.

One last flag before you aim any of this at production. -C, or --check, is the dress rehearsal: modules that model state work out what they would change, report it, and change nothing. That is a real preview for systemd_service, copy, file, user and their relatives. It is not a preview for command and shell, which cannot know what an arbitrary line would do, so they refuse to guess.

terminal
$ ansible web -C -b -m ansible.builtin.shell -a 'systemctl restart nginx'
output
web01.example.com | SKIPPED
web02.example.com | SKIPPED
web03.example.com | SKIPPED

Three SKIPPED lines and zero information. A green dry run of a shell one-liner tells you nothing whatsoever about what the real run will do, and reading it as approval is how people talk themselves into a bad afternoon. So the pre-flight for any ad-hoc change is three commands, in this order. Resolve the pattern with --list-hosts and read the list out loud. Run the read-only version of the change against that same pattern, a status query or a config dump. Then run the real thing with -b, and check that the number of CHANGED lines matches the host count you read a minute ago. Twenty extra seconds, and it is the difference between restarting three web servers and restarting the fleet.

Quick check
01ansible all -m ansible.builtin.ping comes back with "ping": "pong" from a host. What has that actually proven?
Incorrect — The module sends no ICMP at all; the name is a historical joke that catches almost everyone once.
Correct — That is an end-to-end check of name resolution, port, credentials and a working remote Python, all in one command.
Incorrect — A bare port check would still pass with a rejected key or a missing Python; pong requires the module to actually run.
Incorrect — There is no agent. Ansible pushes a module over SSH for that one run and deletes it afterwards.
02Your ansible.cfg sets host_key_checking = True, which is already the default. You run ansible-config dump --only-changed. What do you see?
Correct — --only-changed filters on where a setting came from, not on whether its value differs from the default, so anything your config file touched is listed with its source.
Incorrect — The flag compares origins, not values; a setting written into a config file no longer originates from the default.
Incorrect — The bracket shows the source that won, which here is the config file path, not the built-in default.
Incorrect — ansible-config dump never errors on redundant settings; it prints whatever the config manager resolved.
03You meant to restart nginx everywhere and ran: ansible 'web,dbb' -i inventory.ini -b -m ansible.builtin.systemd_service -a 'name=nginx state=restarted'. The output starts with [WARNING]: Could not match supplied host pattern, ignoring: dbb, then shows three CHANGED results, and the shell reports exit code 0. What happened?
Incorrect — Ansible does no fuzzy matching on host patterns; an element that matches nothing is dropped, not guessed at.
Incorrect — The abort with ERROR! only happens when the entire pattern matches zero hosts; here part of it matched.
Correct — Unmatched elements are warned about and dropped, matched ones still run, and the run counts as a success.
Incorrect — Ad-hoc commands never write to your inventory file; unmatched names are discarded after the warning.

Try this

Run pipx install --include-deps ansible on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: turning off host key checking hands the fleet to whoever answers. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related