Ansible Basics
Configuration management without agents: the control node, inventories and groups, ad-hoc commands, modules, SSH connections and privilege escalation.
Where Ansible fits
Ansible is an open-source automation tool, maintained by Red Hat and its community, that configures machines that already exist: it creates files and directories, writes configuration, installs packages, manages services and users, and runs commands — on one server or on five hundred at once. The Networking topic introduces it for switches and routers; this page covers the same tool for Linux servers, the way Kestrel Energy uses it for the portal web servers web01 and web02 and the PostgreSQL server db01.
In the infrastructure as code landscape, Ansible is a configuration management tool. Terraform and OpenTofu (see Terraform and OpenTofu: First Steps) create the virtual machines, networks and DNS records; Ansible then sets up what runs inside those machines. Many teams use both, in that order.
If you have looped over servers with ssh in a shell
script (see
running a command on many hosts), Ansible is the next step: the same reach, but with a list of
hosts kept in one place, parallel runs, readable YAML instead of
quoting puzzles, and modules that check before they change.
Control node and managed nodes
The control node is the machine where Ansible is installed and from which you run it: an admin's Linux laptop, a jump host or a pipeline runner. It holds the inventory, the playbooks and the credentials. A managed node is a machine Ansible configures; it needs no Ansible installation, only a way in (normally SSH) and, for most modules, a Python interpreter.
Agentless and push-based
Ansible is agentless: there is no Ansible service running on the managed nodes. For each task, Ansible logs in over SSH, copies a small program (the module) to a temporary directory, runs it with the node's Python, reads the JSON result and deletes the program again. That has practical consequences:
- Nothing extra to install, patch or monitor on every server, and no extra port to open: if you can SSH in, Ansible can work.
- Ansible only acts when someone (or a pipeline) runs it. Between runs, nothing corrects a manual change; that is the configuration drift problem, which agent-based tools such as Puppet and Chef handle by checking in on a schedule (see Terraform, Puppet and Chef).
- The control node holds the keys to every managed node. Protect it like any administrative workstation.
Versions and installation
Ansible comes in two packages: ansible-core, the engine with the built-in modules, and ansible, a larger package that bundles ansible-core with a curated set of collections (see Roles and Collections). As of October 2026:
| ansible-core | Released | End of life | Python on the control node | Python on managed nodes |
|---|---|---|---|---|
| 2.22 | planned November 2026 | May 2028 | 3.13–3.15 | 3.9–3.15 |
| 2.21 (current, 2.21.5) | May 2026 | November 2027 | 3.12–3.14 | 3.9–3.14 |
| 2.20 | November 2025 | May 2027 | 3.12–3.14 | 3.9–3.14 |
| 2.19 | July 2025 | November 2026 | 3.11–3.13 | 3.8–3.13 |
The current ansible package is 14.4.0, built on
ansible-core 2.21. Each ansible-core release supports the three most
recent Python versions on the control node, so an older Linux
distribution may be stuck on an older ansible-core: the machine the
output on this page was recorded on has Python 3.11, which is why it
runs ansible-core 2.19 (in its last months of security fixes). The
commands and output look the same on 2.21.
The control node must be Linux, macOS or another Unix-like system;
on Windows, use WSL (Windows Subsystem for Linux). Install
ansible-core in a Python virtual environment, or with
pipx, rather than from an old distribution package:
$ python3 -m venv ~/.venv/ansible
$ ~/.venv/ansible/bin/python -m pip install ansible-core
$ ~/.venv/ansible/bin/ansible --version
Inventories and groups
The
inventory
lists the managed nodes, puts them in groups and holds the variables
that say how to reach them. Kestrel's inventory has a group
web with the two portal servers, a group
db with db01, and a parent group kestrel
that contains both. It can be written as INI or YAML; these two say
exactly the same.
inventory/hosts.ini:
[web]
web01 ansible_host=192.0.2.11
web02 ansible_host=192.0.2.12
[db]
db01 ansible_host=192.0.2.31
[kestrel:children]
web
db
[all:vars]
ansible_connection=local
inventory/hosts.yml:
all:
vars:
ansible_connection: local
children:
kestrel:
children:
web:
hosts:
web01:
ansible_host: 192.0.2.11
web02:
ansible_host: 192.0.2.12
db:
hosts:
db01:
ansible_host: 192.0.2.31
The name before the variables (web01) is the
inventory hostname Ansible uses in its output;
ansible_host is the address it connects to. A
[group:children] section (or children:
in YAML) nests groups. Two groups always exist:
all (every host) and ungrouped (hosts in
no other group).
ansible-inventory shows how Ansible reads the inventory
— the quickest way to check groups and variables. The
ansible.cfg in this project points at
hosts.ini; -i chooses another inventory.
$ cd /home/amara/kestrel-infra/ansible
$ ansible-inventory --graph
$ ansible-inventory --host web01
@all:
|--@ungrouped:
|--@kestrel:
| |--@web:
| | |--web01
| | |--web02
| |--@db:
| | |--db01
{
"ansible_connection": "local",
"ansible_host": "192.0.2.11"
}
ansible-inventory -i inventory/hosts.yml --graph prints
the same tree, and --list prints everything as JSON.
Besides static files, an inventory can be a dynamic source:
an inventory plugin that asks a cloud provider or a CMDB which
machines exist right now.
Host patterns
Every command and play names its targets with a
host pattern: a host, a group, or a combination. A colon
means "or", :& means "and also in", and
:! means "but not in". --list-hosts shows
the result without doing anything:
$ ansible web --list-hosts
$ ansible 'kestrel:!db' --list-hosts
hosts (2):
web01
web02
hosts (2):
web01
web02
Quote patterns with ! or & in single
quotes, or the shell interprets them first. Ansible does not apply
the parts from left to right: it first collects all plain parts,
then applies every &, then every !.
This small model resolves patterns the same way:
groups = {"web": ["web01", "web02"], "db": ["db01"]}
groups["kestrel"] = groups["web"] + groups["db"]
groups["all"] = groups["kestrel"]
def members(name):
"""A group name gives its hosts; any other name is a single host."""
return groups.get(name, [name])
def hosts_for(pattern):
"""Resolve a host pattern: a:b (or), a:&b (and), a:!b (not)."""
plain, both, without = [], [], []
for part in pattern.split(":"):
if part.startswith("&"):
both.append(set(members(part[1:])))
elif part.startswith("!"):
without.append(set(members(part[1:])))
else:
plain += [h for h in members(part) if h not in plain]
for keep in both: # every & after the plain parts
plain = [h for h in plain if h in keep]
for drop in without: # every ! last
plain = [h for h in plain if h not in drop]
return plain
for pattern in ["web", "kestrel:!db", "web:db01", "!web02:web", "kestrel:&web:!web01"]:
print(f"{pattern:24} {', '.join(hosts_for(pattern))}")
web web01, web02 kestrel:!db web01, web02 web:db01 web01, web02, db01 !web02:web web01 kestrel:&web:!web01 web02
!web02:web gives web01 even though the exclusion comes
first: the order in the pattern does not matter. A typing mistake in
a group name is not an error in this model, and in Ansible a pattern
that matches nothing only gives a warning, so always check the host
list before a change.
Ad-hoc commands
An ad-hoc command runs one
module against a host pattern straight from the command line,
without writing a playbook:
ansible <pattern> -m <module> -a
"<arguments>". It is ideal for quick questions and one-off actions; anything you
want to repeat belongs in a
playbook. The first
thing to run against new hosts is the ping module,
which logs in, checks that Python works and answers "pong" (it is
not an ICMP ping):
$ ansible all -m ping
web02 | SUCCESS => {
"changed": false,
"ping": "pong"
}
web01 | SUCCESS => {
"changed": false,
"ping": "pong"
}
db01 | SUCCESS => {
"changed": false,
"ping": "pong"
}
The hosts answer in whatever order they finish, because Ansible
works on several hosts in parallel (five at a time by default; see
forks). Without -m, Ansible
uses the command module, so these two are the same kind
of call:
$ ansible web -m command -a "echo portal check on {{ inventory_hostname }}"
$ ansible db01 -a "echo hello from db01"
web02 | CHANGED | rc=0 >> portal check on web02 web01 | CHANGED | rc=0 >> portal check on web01 db01 | CHANGED | rc=0 >> hello from db01
{{ inventory_hostname }} is filled in per host before
the command runs. Notice CHANGED: the
command module cannot know whether a command changed
anything, so it always says it did. State-based modules can tell.
Here the copy module installs a login message
(files/motd) on all three hosts, twice:
$ ansible all -m copy -a "src=files/motd dest=/home/amara/kestrel-infra/ansible/hosts/{{ inventory_hostname }}/etc/motd mode=0644"
$ ansible all -m copy -a "src=files/motd dest=/home/amara/kestrel-infra/ansible/hosts/{{ inventory_hostname }}/etc/motd mode=0644"
db01 | CHANGED => {
"changed": true,
"checksum": "1e6c89c8285fa64c744308ea9e5b52420062a77d",
"dest": "/home/amara/kestrel-infra/ansible/hosts/db01/etc/motd",
"gid": 1000,
"group": "amara",
"md5sum": "d36a3e9816578fb3792ce2c33282b3fe",
"mode": "0644",
"owner": "amara",
"size": 93,
"src": "/home/amara/.ansible/tmp/ansible-tmp-1791302624.0912912-2480569-60765866083044/.source",
"state": "file",
"uid": 1000
}
…
web02 | SUCCESS => {
"changed": false,
"checksum": "1e6c89c8285fa64c744308ea9e5b52420062a77d",
"dest": "/home/amara/kestrel-infra/ansible/hosts/web02/etc/motd",
"gid": 1000,
"group": "amara",
"mode": "0644",
"owner": "amara",
"path": "/home/amara/kestrel-infra/ansible/hosts/web02/etc/motd",
"size": 93,
"state": "file",
"uid": 1000
}
…
The first run copied the file (CHANGED); the second
compared checksums, found the file already correct and did nothing
(SUCCESS, "changed": false). That is
idempotence, the property that makes it safe to run the same automation again
and again. (The long paths are the folders that stand in for each
server in the test setup; on a real server dest would
be /etc/motd.)
Modules
An Ansible module is the unit
of work in Ansible: a small program, usually written in Python (in
PowerShell for Windows), that performs one kind of action on a
managed node and returns the result as JSON. Most modules are
declarative: you describe the state you want (state: directory, mode: "0644") and the module checks the current
state first and acts only if it differs. ansible-core ships 71
modules in the ansible.builtin
collection; thousands more come in collections.
| Module | What it manages |
|---|---|
ansible.builtin.file |
Directories, permissions, ownership, links |
ansible.builtin.copy |
A file copied from the control node, or given inline |
ansible.builtin.template |
A file generated from a Jinja2 template |
ansible.builtin.lineinfile |
One line in an existing file |
ansible.builtin.apt,
ansible.builtin.dnf,
ansible.builtin.package
|
Installed packages |
ansible.builtin.service,
ansible.builtin.systemd_service
|
Services: started, stopped, enabled at boot |
ansible.builtin.user |
Local user accounts |
ansible.builtin.command,
ansible.builtin.shell
|
Any command, when no module exists (not idempotent by themselves) |
The documentation of every installed module is on the control node.
ansible-doc -l lists them, and
ansible-doc with a module name shows its options:
$ ansible-doc -l | head -4
$ ansible-doc ansible.builtin.copy | head -8
ansible.builtin.add_host Add a host (and alternatively a grou... ansible.builtin.apt Manages apt-packages ansible.builtin.apt_key Add or remove an apt key ansible.builtin.apt_repository Add and remove APT repositories > MODULE ansible.builtin.copy (/home/amara/.venv/ansible/lib/python3.11/site-packages/ansible/modules/copy.py) The [ansible.builtin.copy] module copies a file or a directory structure from the local or remote machine to a location on the remote machine. File system meta-information (permissions, ownership, etc.) may be set, even when the file or directory already exists on the target system. Some meta-information may be copied on request.
Reach for command or shell only when no
module does the job: they run whatever you give them on every run
and always report changed, unless you tell them how to
recognize "already done" (see
Playbooks and Idempotency). shell also passes the command through a shell, with
the quoting and injection risks that brings.
What happens when a module runs
Adding -vvv shows the mechanics. In this excerpt the
connection is local (the test setup); over SSH the
first line says ESTABLISH SSH CONNECTION instead, and
each EXEC runs through SSH:
$ ansible web01 -m ping -vvv
ansible [core 2.19.14]
config file = /home/amara/kestrel-infra/ansible/ansible.cfg
…
<192.0.2.11> ESTABLISH LOCAL CONNECTION FOR USER: amara
<192.0.2.11> EXEC /bin/sh -c '( umask 77 && mkdir -p "` echo /home/amara/.ansible/tmp `"&& mkdir "` echo /home/amara/.ansible/tmp/ansible-tmp-1791302745.072739-2483202-62192243513487 `" && echo ansible-tmp-1791302745.072739-2483202-62192243513487="` echo /home/amara/.ansible/tmp/ansible-tmp-1791302745.072739-2483202-62192243513487 `" ) && sleep 0'
Using module file /home/amara/.venv/ansible/lib/python3.11/site-packages/ansible/modules/ping.py
<192.0.2.11> PUT /home/amara/.ansible/tmp/ansible-local-2483199p3ie2v54/tmpktcey7r4 TO /home/amara/.ansible/tmp/ansible-tmp-1791302745.072739-2483202-62192243513487/AnsiballZ_ping.py
<192.0.2.11> EXEC /bin/sh -c 'chmod u+rwx /home/amara/.ansible/tmp/ansible-tmp-1791302745.072739-2483202-62192243513487/ /home/amara/.ansible/tmp/ansible-tmp-1791302745.072739-2483202-62192243513487/AnsiballZ_ping.py && sleep 0'
<192.0.2.11> EXEC /bin/sh -c '/home/amara/.venv/ansible/bin/python3 /home/amara/.ansible/tmp/ansible-tmp-1791302745.072739-2483202-62192243513487/AnsiballZ_ping.py && sleep 0'
<192.0.2.11> EXEC /bin/sh -c 'rm -f -r /home/amara/.ansible/tmp/ansible-tmp-1791302745.072739-2483202-62192243513487/ > /dev/null 2>&1 && sleep 0'
web01 | SUCCESS => {
"changed": false,
"invocation": {
"module_args": {
"data": "pong"
}
},
"ping": "pong"
}
Four steps, for every task on every host: create a private temporary
directory (umask 77), upload the module packed as
AnsiballZ_ping.py, run it with Python, and remove the
directory. This is why managed nodes need Python, and why a full
disk or a home directory the login user cannot write to on a managed
node can make every task fail.
Connections and SSH
The default connection plugin is ssh, which
calls the normal OpenSSH client. Everything you know about
SSH applies: key pairs,
the agent, ~/.ssh/config and host key checking all work
as usual. Inventory variables adjust the connection per host or
group:
kestrel:
vars:
ansible_user: deploy # log in as this account
ansible_port: 22
ansible_ssh_private_key_file: ~/.ssh/kestrel_deploy_ed25519
children:
web:
hosts:
web01:
ansible_host: 192.0.2.11
-
Use
SSH key pairs, ideally loaded in an
agent,
not passwords. Never put
ansible_passwordin plain text in an inventory; if a password is unavoidable, encrypt it (see Secrets in Automation). - Host key checking is on by default: Ansible refuses a host whose key is not in known_hosts or has changed. Add new keys deliberately (for example from your provisioning output) instead of switching the check off.
-
Other connection plugins exist:
local(run on the control node),ansible.netcommon.network_clifor network devices, and WinRM or PSRP (PowerShell Remoting Protocol) for Windows, which can also be managed over SSH.
When Ansible cannot connect, the host is reported as
UNREACHABLE rather than failed. This was one real
attempt to reach a documentation address that is never routed. The
inventory is given inline (-i '192.0.2.10,'; the
trailing comma makes it a list of hosts rather than a file name),
and the SSH options make it give up after three seconds and keep the
test machine's own known_hosts untouched:
$ ansible all -i '192.0.2.10,' -m ping -u amara \
-e "ansible_ssh_common_args='-o ConnectTimeout=3 -o UserKnownHostsFile=/home/amara/kestrel-infra/known_hosts -o StrictHostKeyChecking=no'"
[ERROR]: Task failed: Failed to connect to the host via ssh: ssh: connect to host 192.0.2.10 port 22: Connection timed out
Origin: <adhoc 'ping' task>
{'action': 'ping', 'args': {}, 'timeout': 0, 'async_val': 0, 'poll': 15}
192.0.2.10 | UNREACHABLE! => {
"changed": false,
"msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 192.0.2.10 port 22: Connection timed out",
"unreachable": true
}
The exit code was 4, Ansible's code for "one or more hosts were
unreachable" (a failed task gives 2). The text after "Failed to
connect to the host via ssh:" is OpenSSH's own message, and it
points to the cause: Connection timed out (wrong
address or a firewall), Connection refused (host up, no
SSH listening), Permission denied (publickey) (wrong
user or key), Host key verification failed (unknown or
changed host key).
Troubleshooting Infrastructure as Code
goes through these one by one; the quickest test is the same
ssh command by hand.
Privilege escalation with become
Ansible logs in as an ordinary user. Many tasks — installing a
package, writing to /etc, restarting a service — need
root rights. become is Ansible's
privilege escalation keyword: with become: true, a task
or play runs as another user, root unless
become_user says otherwise, by default through
sudo
(become_method can also be su and others).
- name: Configure PostgreSQL on db01
hosts: db
become: false # the default: tasks run as the login user
tasks:
- name: Install the PostgreSQL client tools
ansible.builtin.apt:
name: postgresql-client
state: present
become: true # only this task runs as root
- name: Create the billing export directory as the postgres user
ansible.builtin.file:
path: /var/lib/postgresql/billing-export
state: directory
mode: "0750"
become: true
become_user: postgres
If sudo needs a password, give it at run time with
-K (--ask-become-pass); without it the
task fails with "Missing sudo password". In a pipeline, the deploy
account usually gets a
sudoers
rule instead. Since ansible-core 2.19, a timeout while waiting for
the privilege escalation prompt makes the host
unreachable instead of failing the task, so
ignore_errors no longer hides it (ignore_unreachable
does).
Configuration: ansible.cfg
ansible.cfg is Ansible's configuration file, in INI format: it sets defaults such as the inventory to use, the number of parallel hosts, the remote user and the SSH options, so that you do not have to repeat them on every command line. Ansible uses the first file it finds, in this order, and does not merge them:
-
the file named in the
ANSIBLE_CONFIGenvironment variable; ansible.cfgin the current directory;~/.ansible.cfgin your home directory;/etc/ansible/ansible.cfg.
Keep an ansible.cfg in the project repository, next to
the playbooks, so everyone who runs them gets the same settings.
Kestrel's file:
[defaults]
inventory = inventory/hosts.ini
forks = 10
vault_password_file = .vault-pass
forks is the number of hosts Ansible works on at the
same time; the default is 5, which is slow for a hundred servers and
can be too much for a small control node. The vault password file
belongs to
Secrets in Automation.
ansible-config dump --only-changed shows every setting
that differs from the default and where it came from (lines that
came from environment variables of the test machine are left out):
$ ansible-config dump --only-changed
… CONFIG_FILE() = /home/amara/kestrel-infra/ansible/ansible.cfg DEFAULT_FORKS(/home/amara/kestrel-infra/ansible/ansible.cfg) = 10 DEFAULT_HOST_LIST(/home/amara/kestrel-infra/ansible/ansible.cfg) = ['/home/amara/kestrel-infra/ansible/inventory/hosts.ini'] … DEFAULT_VAULT_PASSWORD_FILE(/home/amara/kestrel-infra/ansible/ansible.cfg) = /home/amara/kestrel-infra/ansible/.vault-pass …
ansible-config list documents every setting with its
default, environment variable and INI key; most settings can also be
set with an ANSIBLE_… environment variable, which wins
over the file.