By the end you'll know
- What a playbook is: plays, tasks and modules, written in YAML
- How to check a playbook before it touches anything, and what a dry run can't tell you
- How to read a run, and its recap at the end
- Why the second run of a good playbook changes nothing
- How a playbook finds and undoes changes made by hand
- How to make a
shelltask safe to run again withcreates - What happens when a task fails, and what a YAML mistake looks like
Before you start
- You need: Lessons 1 and 2 done, with the three servers running, and
aclinstalled from Lesson 2. - Get the new file: in
ansible-practice,git pull. You'll havesite.yml, the playbook this lesson runs. - Where commands run: your own computer's terminal, in
ansible-practice.
From commands to a playbook
Lesson 2 installed packages and made users with ad-hoc commands. They work, but they're gone the moment you've typed them. Next month, nobody remembers which packages, which user, which options, or in what order. Someone has to piece it together from shell history, if it's still there.
A playbook writes it all down, in a file you keep in Git, review, and run again whenever you like. Think of the difference between telling a new colleague how you like a room set up, and leaving them a checklist. The checklist is the same every time, anyone can follow it, and when the room's wrong, you can see exactly which item isn't done. A playbook is that checklist, and Ansible is the colleague who follows it to the letter on every server, and only fixes what's not already right.
Reading site.yml
site.yml is the usual name for the playbook that sets up everything. Here it is, in full:
# The bookshop servers, described. Run with: ansible-playbook site.yml
# Dry run first: ansible-playbook site.yml --check --diff
- name: Baseline for every bookshop server
hosts: bookshop
become: true
tasks:
- name: Install the packages every server needs
ansible.builtin.apt:
name:
- acl
- htop
- tree
state: present
update_cache: true
cache_valid_time: 3600
- name: Show who manages this server when someone logs in
ansible.builtin.copy:
dest: /etc/motd
content: |
This is {{ inventory_hostname }}, part of the bookshop.
It's managed by Ansible: changes made here by hand will be put back.
owner: root
group: root
mode: "0644"
- name: Create the deploy user
ansible.builtin.user:
name: deploy
shell: /bin/bash
state: present
- name: Record when this server was first set up
ansible.builtin.shell:
cmd: date -u +%Y-%m-%dT%H:%M:%SZ > /etc/bookshop-first-setup
creates: /etc/bookshop-first-setup
- name: Web servers
hosts: web
become: true
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Keep nginx running, and start it at boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
The shape, from the outside in:
- A playbook is a list of plays. Each
- name:at the left edge starts one. This one has two. - A play says which servers, and what to do there.
hosts: bookshopis a pattern from Lesson 2.become: trueis--become, for the whole play. tasks:is a list of tasks, run in order. Each task runs on every server in the play before the next task starts.- A task is one module, with its options. The same modules as Lessons 1 and 2,
apt,copy,user,service, with the options that werename=tree state=presentwritten out asname: treeandstate: present, one per line. - Every task has a
name. It's what you see while it runs, so write it as a sentence about the result: "Keep nginx running", not "service".
A few YAML details carry meaning:
- Indentation is structure. Two spaces per level, never tabs. A task's options are indented under the module; the module is indented under the task's
-. Get it wrong, and the meaning changes or the file won't load. (The last section breaks it on purpose.) -starts a list item: the plays, the tasks, and the three package names.|starts a block of text, kept exactly as written, line breaks and all. That's the message of the day."0644"is in quotes so YAML keeps it as text. Unquoted,0644could be read as a number, and file permissions have caught many people out that way.
ansible.builtin.apt is the module's full name, its fully qualified collection name. apt alone works, as in Lessons 1 and 2, but the full name is the recommended habit in files: it says exactly which apt, when collections can bring their own modules with the same short name. And {{ inventory_hostname }} is the variable from Lesson 2, filled in per server, so each one's message names itself.
Before running anything
Three checks that touch no servers at all. Is it valid?
ansible-playbook site.yml --syntax-check
playbook: site.yml
Just the file's name: no complaints. Which servers will each play run on?
ansible-playbook site.yml --list-hosts
playbook: site.yml
play #1 (bookshop): Baseline for every bookshop server TAGS: []
pattern: ['bookshop']
hosts (3):
web2
db1
web1
play #2 (web): Web servers TAGS: []
pattern: ['web']
hosts (2):
web2
web1
And what will it do?
ansible-playbook site.yml --list-tasks
playbook: site.yml
play #1 (bookshop): Baseline for every bookshop server TAGS: []
tasks:
Install the packages every server needs TAGS: []
Show who manages this server when someone logs in TAGS: []
Create the deploy user TAGS: []
Record when this server was first set up TAGS: []
play #2 (web): Web servers TAGS: []
tasks:
Install nginx TAGS: []
Keep nginx running, and start it at boot TAGS: []
That list is the reason task names matter: it reads like the plan, because it is. (TAGS are labels for running only some tasks; this playbook has none.)
A dry run, and its blind spot
--check asks every module what it would change, without changing anything. --diff adds what files would look like before and after. Together, they're the playbook's dry run, Lesson 7 of the SQL track's "SELECT before you UPDATE":
ansible-playbook site.yml --check --diff
PLAY [Baseline for every bookshop server] **************************************
TASK [Gathering Facts] *********************************************************
ok: [web2]
ok: [web1]
ok: [db1]
TASK [Install the packages every server needs] *********************************
The following NEW packages will be installed:
tree
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
changed: [db1]
The following NEW packages will be installed:
tree
0 upgraded, 1 newly installed, 0 to remove and 238 not upgraded.
changed: [web1]
...
TASK [Show who manages this server when someone logs in] ***********************
--- before
+++ after: /etc/motd
@@ -0,0 +1,2 @@
+This is db1, part of the bookshop.
+It's managed by Ansible: changes made here by hand will be put back.
changed: [db1]
...
TASK [Create the deploy user] **************************************************
changed: [web2]
changed: [db1]
changed: [web1]
TASK [Record when this server was first set up] ********************************
changed: [web2]
changed: [web1]
changed: [db1]
PLAY [Web servers] *************************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1]
ok: [web2]
TASK [Install nginx] ***********************************************************
The following additional packages will be installed:
nginx-common
Suggested packages:
fcgiwrap nginx-doc ssl-cert
The following NEW packages will be installed:
nginx nginx-common
0 upgraded, 2 newly installed, 0 to remove and 238 not upgraded.
changed: [web1]
...
TASK [Keep nginx running, and start it at boot] ********************************
fatal: [web1]: FAILED! => {"changed": false, "msg": "Could not find the requested service nginx: host"}
fatal: [web2]: FAILED! => {"changed": false, "msg": "Could not find the requested service nginx: host"}
PLAY RECAP *********************************************************************
db1 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web1 : ok=7 changed=5 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
web2 : ok=7 changed=5 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
(... marks where the same output for the other servers is left out.) Read it top to bottom:
Gathering Factsruns first in every play: thesetupmodule from Lesson 1, collecting each server's facts. It's whyinventory_hostnameand the operating system are known.- Packages: only
treewould be installed.aclis there from Lesson 2, andhtopcomes with Ubuntu, soapthas nothing to do for them. - The message of the day: the diff shows exactly what
/etc/motdwould contain,+for added lines.--- beforeis empty: the file doesn't exist yet. - nginx would be installed, with the package it depends on.
And then the last task fails. "Could not find the requested service nginx". Of course it can't: it's a dry run, so nginx was never really installed. The service module looked for a service that, in this pretend world, doesn't exist yet. That's the dry run's blind spot: a task that depends on an earlier task's change can't be checked properly, because the earlier change didn't happen. A failure in --check is a reason to look, not always a real problem. Here, it's expected.
The real run, and its recap
time ansible-playbook site.yml
PLAY [Baseline for every bookshop server] **************************************
TASK [Gathering Facts] *********************************************************
ok: [db1]
ok: [web1]
ok: [web2]
TASK [Install the packages every server needs] *********************************
changed: [web1]
changed: [web2]
changed: [db1]
TASK [Show who manages this server when someone logs in] ***********************
changed: [db1]
changed: [web2]
changed: [web1]
TASK [Create the deploy user] **************************************************
changed: [web2]
changed: [web1]
changed: [db1]
TASK [Record when this server was first set up] ********************************
changed: [db1]
changed: [web2]
changed: [web1]
PLAY [Web servers] *************************************************************
TASK [Gathering Facts] *********************************************************
ok: [web2]
ok: [web1]
TASK [Install nginx] ***********************************************************
changed: [web1]
changed: [web2]
TASK [Keep nginx running, and start it at boot] ********************************
ok: [web2]
ok: [web1]
PLAY RECAP *********************************************************************
db1 : ok=5 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web1 : ok=8 changed=5 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=8 changed=5 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
real 0m16.685s
Seventeen seconds, three servers, and the nginx task that failed in the dry run is now ok, not even changed. On Ubuntu, installing nginx starts it and sets it to start at boot by itself, so by the time this task looked, it was already as described. It stays in the playbook anyway: it says what must be true, and if nginx is ever stopped, this task starts it.
The play recap is the part to read every time. One line per server:
ok: tasks that ran, changed or not.changed: tasks that changed something.unreachable: Ansible couldn't connect, likedb1in Lesson 1.failed: tasks that failed. A failure stops that server's run; the others carry on.skipped: tasks that didn't apply to that server.rescuedandignored: failures that were handled on purpose, Lesson 6.
Anything other than zero in unreachable or failed needs a look. Check that it worked:
ansible bookshop -m command -a "cat /etc/motd /etc/bookshop-first-setup"
db1 | CHANGED | rc=0 >>
This is db1, part of the bookshop.
It's managed by Ansible: changes made here by hand will be put back.
2026-11-01T04:44:15Z
web2 | CHANGED | rc=0 >>
This is web2, part of the bookshop.
It's managed by Ansible: changes made here by hand will be put back.
2026-11-01T04:44:15Z
web1 | CHANGED | rc=0 >>
This is web1, part of the bookshop.
It's managed by Ansible: changes made here by hand will be put back.
2026-11-01T04:44:15Z
Each server's own message, and the time it was first set up. And nginx is answering, checked with the uri module, which makes a web request from each server to itself:
ansible web -m uri -a "url=http://localhost return_content=false" -o | cut -c1-140
web1 | SUCCESS => {"accept_ranges": "bytes","ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3.12"},"changed": false,"conn
web2 | SUCCESS => {"accept_ranges": "bytes","ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3.12"},"changed": false,"conn
SUCCESS means the page answered with status 200; uri fails on anything else by default. (Lines cut short with cut.)
The second run
The real test of a playbook:
time ansible-playbook site.yml
...
PLAY RECAP *********************************************************************
db1 : ok=5 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web1 : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
real 0m7.255s
changed=0 on every server, every task ok, and in under half the time, because there was nothing to do. A good playbook's second run changes nothing. If it does, something in it isn't idempotent, and that's worth fixing, because a playbook that always says changed hides the day something really changed.
That includes the shell task. A shell command can't know whether it's done already, so on its own it would run, and overwrite the setup time, every run. creates: /etc/bookshop-first-setup tells it: if this file already exists, skip it. That's how to make a command or shell task behave: give it a way to know when it's done. (Its sibling, removes, runs the task only if a file does exist.)
Drift: a change made by hand
Someone logs in to web1 and adds a note to the message of the day:
ansible web1 -m shell -a "echo \"Back in 5 mins - Sam\" >> /etc/motd" --become
The server no longer matches the playbook. That's called drift, and it's how every hand-managed server ends up different from its neighbors. A dry run finds it, and shows exactly what:
ansible-playbook site.yml --check --diff --limit web1
...
TASK [Show who manages this server when someone logs in] ***********************
--- before: /etc/motd
+++ after: /etc/motd
@@ -1,3 +1,2 @@
This is web1, part of the bookshop.
It's managed by Ansible: changes made here by hand will be put back.
-Back in 5 mins - Sam
changed: [web1]
...
PLAY RECAP *********************************************************************
web1 : ok=8 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
One task would change, and the diff shows the line it would remove (-). Every other task: ok. Run it for real:
ansible-playbook site.yml --limit web1
ansible web1 -m command -a "cat /etc/motd"
...
TASK [Show who manages this server when someone logs in] ***********************
changed: [web1]
...
PLAY RECAP *********************************************************************
web1 : ok=8 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web1 | CHANGED | rc=0 >>
This is web1, part of the bookshop.
It's managed by Ansible: changes made here by hand will be put back.
Put back, exactly as described. That's what the message itself warns about, and it cuts both ways: if a change is worth keeping, it goes in the playbook, or the next run undoes it. Running --check --diff on a schedule, and alerting when it says changed, is a cheap way to find drift across a whole fleet.
When it goes wrong
A playbook with a typo in a package name. The when: line, which Lesson 6 covers, limits that task to the web servers:
- name: A playbook with mistakes in it
hosts: bookshop
become: true
tasks:
- name: Install a package that doesn't exist, on the web servers
ansible.builtin.apt:
name: ngnix
state: present
when: "'web' in group_names"
- name: A task after the failure
ansible.builtin.command: hostname
PLAY [A playbook with mistakes in it] ******************************************
TASK [Gathering Facts] *********************************************************
ok: [web2]
ok: [web1]
ok: [db1]
TASK [Install a package that doesn't exist, on the web servers] ****************
skipping: [db1]
fatal: [web2]: FAILED! => {"changed": false, "msg": "No package matching 'ngnix' is available"}
fatal: [web1]: FAILED! => {"changed": false, "msg": "No package matching 'ngnix' is available"}
TASK [A task after the failure] ************************************************
changed: [db1]
PLAY RECAP *********************************************************************
db1 : ok=2 changed=1 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web1 : ok=1 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
web2 : ok=1 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
A failed task stops the run for that server only. web1 and web2 failed at the typo and went no further: the next task ran on db1 alone. db1 was skipping the bad task, since it isn't a web server, and carried on. The recap says it all: failed=1 for the two web servers. Fix the typo, run again, and idempotence means the servers that finished just say ok for everything already done.
And a YAML mistake: become: true indented one space too far left, so it's not part of the play or the task:
- name: Bad indentation
hosts: all
tasks:
- name: Ping
ansible.builtin.ping:
become: true
ansible-playbook broken.yml --syntax-check
ERROR! We were unable to read either as JSON nor YAML, these are the errors we got from each:
JSON: Expecting value: line 1 column 1 (char 0)
Syntax Error while loading YAML.
did not find expected key. while parsing a block mapping
in "<unicode string>", line 1, column 3
did not find expected key
in "<unicode string>", line 6, column 4
The error appears to be in '/Users/you/Apps/ansible-practice/broken.yml': line 6, column 4, but may
be elsewhere in the file depending on the exact syntax problem.
The offending line appears to be:
ansible.builtin.ping:
become: true
^ here
The ^ here points at the right line, and the message admits the real mistake "may be elsewhere". With YAML, the problem is nearly always indentation: line it up with what it belongs to. --syntax-check catches this kind of mistake before anything runs, which is why it's step one. (Also note - name: Ping is indented the same as tasks: here. That one's allowed: YAML lets list items sit level with their parent. Pick one style and keep to it.)
In real systems
- Playbooks live in Git, and changes go through review, like any code. A pull request that changes
site.yml, with the output of--check --diffpasted in, shows the reviewer exactly what will happen to which servers. - CI runs the checks.
--syntax-checkand a linter on every pull request, Lesson 10, so broken YAML never reachesmain. - The same habit as everywhere else in this site: check, dry-run, run, verify. It's CivicGrid's rule for data, "always dry-run before writing", applied to servers.
Spot the problem
Work out what's wrong before you read the explanation.
The playbook that never settles. A team's playbook ends with a task that runs systemctl restart nginx through the command module, "to make sure the config is loaded". Every run, the recap says changed=1 for every web server, even when nothing else changed, and nginx restarts each time, dropping visitors' connections for a moment. What's wrong, and what's the better way? The command task isn't idempotent: it restarts every time, and always reports changed, so the recap can never say "nothing to do", and real changes are hidden among the fake ones. Restarting is only needed when the config actually changes. Ansible has a feature for exactly that, a handler: a task that runs only when another task reports a change. Lesson 5 uses one.
Keep this
| To | Run or write |
|---|---|
| Check a playbook is valid | ansible-playbook site.yml --syntax-check |
| See which servers, and which tasks | --list-hosts, --list-tasks |
| Dry run, with file changes shown | ansible-playbook site.yml --check --diff |
| Run it | ansible-playbook site.yml |
| Run it on some servers only | ansible-playbook site.yml --limit web1 |
| A play | - name: ..., hosts: web, become: true, tasks: |
| A task | - name: ..., then the module and its options, indented |
| A file with given content | ansible.builtin.copy with dest, content, mode: "0644" |
| A service running, at boot too | ansible.builtin.service with state: started, enabled: true |
| A shell task that runs once | creates: /path/it/makes |
| Check a web page answers | ansible web -m uri -a "url=http://localhost" |
Where you are now
You've written a playbook, checked it, dry-run it, run it, proved the second run changes nothing, and watched it find and undo a change made by hand. Lesson 4 makes it flexible: variables in the playbook, the facts Ansible gathers, and using them to make each server's setup fit that server.
Check yourself
In --check mode, 'Keep nginx running' failed: could not find the service nginx. Why?
The real run said ok, not changed, for 'Keep nginx running'. Why?
The second run's recap shows changed=0 on every server. What does that tell you?
Why does the shell task have creates: /etc/bookshop-first-setup?
Someone added a line to /etc/motd by hand. What does the next playbook run do?
A task fails on web1 and web2. What happens to db1, which was skipping that task?
Key terms
- Playbook: a YAML file of plays, run with
ansible-playbook. - Play: which servers, and the tasks to run on them.
- Task: one module with its options, and a name.
- Fully qualified collection name: a module's full name, like
ansible.builtin.apt. - Check mode:
--check, a dry run that reports what would change and changes nothing. - Diff mode:
--diff, showing files before and after. - Play recap: the summary at the end of a run:
ok,changed,unreachable,failed,skippedfor each server. - Drift: a server no longer matching its description, usually from a change made by hand.
Go deeper
- The Ansible documentation: Intro to playbooks (opens in a new tab), and Validating tasks: check mode and diff mode (opens in a new tab).
- Try: add
jqto the package list, then run--check --diff. How many tasks would change, and on which servers? - Try: stop nginx by hand on
web2(ansible web2 -m command -a "systemctl stop nginx" --become), then run the playbook. Which task changes, and what doesurisay before and after?