Ansible basics · Lesson 3 of 11 · about 30 minutes

Your first playbook

Ad-hoc commands are typed once and forgotten. A playbook writes the whole setup down: packages, a file, a user, nginx, in order, for the right servers. This lesson writes one, checks it, dry-runs it (and finds the dry run's blind spot), runs it, runs it again to change nothing, catches a hand edit and puts it back, and breaks it on purpose to read the errors.

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 shell task safe to run again with creates
  • 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 acl installed from Lesson 2.
  • Get the new file: in ansible-practice, git pull. You'll have site.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:

YAML
# 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: bookshop is a pattern from Lesson 2. become: true is --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 were name=tree state=present written out as name: tree and state: 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, 0644 could 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?

BASH
ansible-playbook site.yml --syntax-check
OUTPUT

playbook: site.yml

Just the file's name: no complaints. Which servers will each play run on?

BASH
ansible-playbook site.yml --list-hosts
OUTPUT

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?

BASH
ansible-playbook site.yml --list-tasks
OUTPUT

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":

BASH
ansible-playbook site.yml --check --diff
OUTPUT

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 Facts runs first in every play: the setup module from Lesson 1, collecting each server's facts. It's why inventory_hostname and the operating system are known.
  • Packages: only tree would be installed. acl is there from Lesson 2, and htop comes with Ubuntu, so apt has nothing to do for them.
  • The message of the day: the diff shows exactly what /etc/motd would contain, + for added lines. --- before is 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

BASH
time ansible-playbook site.yml
OUTPUT

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, like db1 in 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.
  • rescued and ignored: failures that were handled on purpose, Lesson 6.

Anything other than zero in unreachable or failed needs a look. Check that it worked:

BASH
ansible bookshop -m command -a "cat /etc/motd /etc/bookshop-first-setup"
OUTPUT
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:

BASH
ansible web -m uri -a "url=http://localhost return_content=false" -o | cut -c1-140
OUTPUT
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:

BASH
time ansible-playbook site.yml
OUTPUT
...
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:

BASH
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:

BASH
ansible-playbook site.yml --check --diff --limit web1
OUTPUT
...
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:

BASH
ansible-playbook site.yml --limit web1
ansible web1 -m command -a "cat /etc/motd"
OUTPUT
...
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:

YAML
- 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
OUTPUT

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:

YAML
- name: Bad indentation
  hosts: all
  tasks:
  - name: Ping
    ansible.builtin.ping:
   become: true
BASH
ansible-playbook broken.yml --syntax-check
OUTPUT
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 --diff pasted in, shows the reviewer exactly what will happen to which servers.
  • CI runs the checks. --syntax-check and a linter on every pull request, Lesson 10, so broken YAML never reaches main.
  • 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

ToRun or write
Check a playbook is validansible-playbook site.yml --syntax-check
See which servers, and which tasks--list-hosts, --list-tasks
Dry run, with file changes shownansible-playbook site.yml --check --diff
Run itansible-playbook site.yml
Run it on some servers onlyansible-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 contentansible.builtin.copy with dest, content, mode: "0644"
A service running, at boot tooansible.builtin.service with state: started, enabled: true
A shell task that runs oncecreates: /path/it/makes
Check a web page answersansible 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

  1. In --check mode, 'Keep nginx running' failed: could not find the service nginx. Why?

  2. The real run said ok, not changed, for 'Keep nginx running'. Why?

  3. The second run's recap shows changed=0 on every server. What does that tell you?

  4. Why does the shell task have creates: /etc/bookshop-first-setup?

  5. Someone added a line to /etc/motd by hand. What does the next playbook run do?

  6. 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, skipped for each server.
  • Drift: a server no longer matching its description, usually from a change made by hand.

Go deeper