The problem
Picture the fleet this chapter gives you: a load balancer lb-1, two web servers web-1 and web-2, a database host db-1. All four are Ubuntu 26.04 servers you reach over SSH, exactly like the box you have been using since chapter 1.
Now someone asks for one small change: "set vm.swappiness=10 on every server and add the new on-call engineer's SSH key". You know how to do it on one machine - sysctl, an editor, authorized_keys (5.3, 1.17). Four machines means four SSH sessions, four chances to typo, and no record of what you did. Forty machines means a script. And the script is where the real trouble starts.
What you need to know already: 1.1 and 1.17 (SSH, keys, known_hosts), 1.11 (sudo, apt), 2.1 and 2.5 (systemd units and services), 6.1 (bash scripts, set -euo pipefail), 7.11 (structured data and jq).
Three ways to get a server into shape
A script (setup.sh copied over and run) says how: "append this line, create this user, restart that service". Run it twice and it appends the line twice, fails because the user exists, restarts a service that did not need it. Run it on a server that is halfway there and nobody knows what happens. Scripts are imperative: a list of steps.
A golden image bakes everything into the disk image the server boots from. Every new server is identical. But the servers already running do not change when the image does: you replace them, which is cheap for some machines and not an option for a database server with 2 TB of data.
Configuration management (Ansible, Puppet, Chef, Salt) says what: "this line must be in /etc/sysctl.conf", "user deploy must exist with this key", "nginx must be installed and running". The tool looks at the server, compares it with what you described, and changes only what differs. That is declarative: you describe the desired state, the tool works out the steps.
Later (Ch 10): container images are the golden-image idea taken all the way: the server (or the container) is replaced, never changed.
Idempotency: the property that matters
An operation is idempotent when doing it twice has the same effect as doing it once. echo "vm.swappiness=10" >> /etc/sysctl.conf is not: the second run adds a second line. "Make sure the line vm.swappiness=10 is in the file" is: the second run finds it and does nothing.
Ansible's modules are written to be idempotent, and every result says which case it was:
ok: [web-1] the host was already in the desired state: nothing was done
changed: [web-2] something was different and Ansible changed it
That gives you a free test that no script has: run it twice. The second run of a good playbook reports changed=0 on every host. When it does not, something in your playbook is not idempotent - and that is a bug, not a cosmetic issue, because "changed" is also what triggers restarts later in this chapter.
In an interview: "Idempotent means I can run it again safely: the second run finds everything already in the desired state, changes nothing and reports ok. That is what lets you run configuration management on a schedule, and it is why a shell: task that always reports changed is a smell."
Push and pull
Pull tools (Puppet, Chef, Salt in its usual setup) put an agent on every server. The agent wakes up every 30 minutes, asks a central server for its configuration and applies it. You need the agent installed, a server to run, and certificates between them - but drift is corrected all the time.
Push is Ansible's default: nothing runs on the servers until you run a command on the control node (the machine where Ansible is installed - here, the lab box). It connects to each managed node over plain SSH, does the work and disconnects. Ansible is agentless: the managed nodes only need two things they already have:
- an SSH server you can log in to (with a key, not a password - 1.17);
- Python 3, because each module is a small Python program Ansible copies over and runs.
control node (oncall-lab) managed nodes
ansible-playbook site.yml --- ssh ---> web-1: python3 module.py -> JSON back
--- ssh ---> web-2: python3 module.py -> JSON back
--- ssh ---> db-1: ...
The module runs on the host, does its comparison there (is nginx installed? is the line in the file?) and sends back a small JSON result: changed or not, plus details. Ansible prints it as one ok: or changed: line.
ansible-pull turns Ansible around for the cases where pull wins (thousands of laptops, servers that are not always reachable): each node clones a git repository with the playbooks and runs them against itself on a timer.
The lab fleet
The hosts exist while you work on this chapter. They are full Ubuntu servers: their own filesystem, users, systemd, packages, SSH server and IP address on 10.0.5.0/24. Everything you learnt about Linux works on them:
$ ssh web-1 'grep PRETTY /etc/os-release; hostname -I; systemctl is-active ssh'
PRETTY_NAME="Ubuntu 26.04.1 LTS"
10.0.5.11
active
The first ssh to each host asks about its host key; answer yes (1.17 explains why it asks). The box's /etc/hosts has a block with their names, so web-1 resolves:
$ grep -A6 'managed hosts' /etc/hosts
# BEGIN managed hosts (lab)
10.0.5.10 lb-1 lb-1.lab
10.0.5.11 web-1 web-1.lab
10.0.5.12 web-2 web-2.lab
10.0.5.21 db-1 db-1.lab
# END managed hosts (lab)
Ansible on Ubuntu 26.04
Ubuntu ships two packages:
- ansible-core - the engine: the
ansible*commands and theansible.builtinmodules (around 70:apt,copy,service,user...). Version 2.20.1 on 26.04. - ansible - the "batteries included" package: ansible-core plus about 90 collections (bundles of modules from the community:
ansible.posix,community.general...). Version 13.1.0 on 26.04, which is built on core 2.20.
You install the second one (sudo apt install -y ansible) in the first mission. Then:
$ ansible --version
ansible [core 2.20.1]
config file = None
configured module search path = ['/home/learner/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /usr/lib/python3/dist-packages/ansible
ansible collection location = /home/learner/.ansible/collections:/usr/share/ansible/collections
executable location = /usr/bin/ansible
python version = 3.14.4 (main, Aug 14 2026, 11:02:17) [GCC 15.2.0] (/usr/bin/python3.14)
jinja version = 3.1.6
pyyaml version = 6.0.2 (with libyaml v0.2.5)
Read it top to bottom: the core version, which ansible.cfg it found (none yet), where it looks for modules and collections, and the Python it runs on. When something behaves differently from a blog post, the first question is "which version?" - 2.19 rewrote templating and error messages, and most articles online predate it.
What Ansible is not
- It is not a provisioning tool: it configures servers that exist. Creating the VMs, networks and DNS records is another tool's job.
- It is not a monitoring tool: it changes things when you run it, and only then. Between runs, someone can still change a server by hand (drift).
- It is not magic idempotency: a
shell:orcommand:task runs every time and reports changed every time, unless you tell Ansible how to know better. Half of this chapter is about that.
Later (Ch 12): Terraform is the provisioning tool, and the chapter's last lesson puts the tools side by side.
What you can now do
- Explain imperative scripts vs declarative configuration management, and why a golden image does not help servers that are already running.
- Define idempotency and use "run it twice, expect changed=0" as a test.
- Describe push (Ansible over SSH, agentless) vs pull (an agent per node), and what a managed node needs: SSH and Python 3.
- Tell ansible-core from the
ansiblepackage, and readansible --version.