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.

The control node control01 holds ansible-core, the inventory, the playbooks, ansible.cfg and the SSH key. It connects over SSH on port 22 to three managed nodes: web01 at 192.0.2.11, web02 at 192.0.2.12 and db01 at 192.0.2.31. Each managed node runs only sshd and Python; there is no Ansible agent on them. control01 control node ansible-core inventory/hosts.ini site.yml ansible.cfg ~/.ssh/id_ed25519 SSH :22 web01 · 192.0.2.11 sshd + Python, no agent web02 · 192.0.2.12 sshd + Python, no agent db01 · 192.0.2.31 sshd + Python, no agent
One control node pushes to many managed nodes. Nothing listens for Ansible on the managed nodes; Ansible logs in like an admin would.

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_password in 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_cli for 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:

  1. the file named in the ANSIBLE_CONFIG environment variable;
  2. ansible.cfg in the current directory;
  3. ~/.ansible.cfg in your home directory;
  4. /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.