Where to start First steps track · 15 stages
Linux Labs

ansible-playbook: running a playbook

14 min Ubuntu 24.04 · ansible-core 2.16

Runs an Ansible playbook on the inventory machines and reports ok, changed, skipping or failed for each task.

Cheat sheet

8 commands Printable version
  • ansible-playbook -i inventory.ini site.yml run
  • ansible-playbook -i inventory.ini site.yml -C -D what would change
  • ansible-playbook -i inventory.ini site.yml --syntax-check
  • ansible-playbook -i inventory.ini site.yml --list-tasks
  • ansible-playbook -i inventory.ini site.yml -l node2 one machine
  • ansible-playbook -i inventory.ini site.yml -t page tagged tasks
  • ansible-playbook -i inventory.ini site.yml -e site_title=Demo
  • ansible-playbook -i inventory.ini site.yml -v more detail

Overview

ansible-playbook takes a playbook — a YAML file with plays — and an inventory, connects to the machines and runs the tasks in order. What an inventory, modules and one-off ansible commands are is in the ansible article. The lab machine has ansible-core 2.16 and the ansible 9.2 package with 103 collections (ansible-galaxy collection list); there is no network to the outside, so collections cannot be installed from Galaxy.

The playbook for the examples is site.yml:

yaml
- name: Demo site
  hosts: local
  become: true
  vars:
    site_title: Demo
  tasks:
    - name: Site directory
      ansible.builtin.file:
        path: /srv/demo
        state: directory
        mode: "0755"

    - name: Page from template
      ansible.builtin.template:
        src: index.html.j2
        dest: /srv/demo/index.html
        mode: "0644"
      notify: Reload nginx
      tags: page

    - name: Stamp
      ansible.builtin.shell: date > /srv/demo/stamp

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

The template templates/index.html.j2 sits next to the playbook — template looks for src in the templates/ directory:

text
<h1>{{ site_title }}</h1>
<p>{{ ansible_hostname }}</p>

The inventory inventory.ini is one machine, this one, without SSH:

ini
[local]
localhost ansible_connection=local

Reading a run

The first run (the stars in the headers are shortened, the summary line is wrapped):

text
student@node1:~/demo$ ansible-playbook -i inventory.ini site.yml

PLAY [Demo site] ***

TASK [Gathering Facts] ***
ok: [localhost]

TASK [Site directory] ***
changed: [localhost]

TASK [Page from template] ***
changed: [localhost]

TASK [Stamp] ***
changed: [localhost]

RUNNING HANDLER [Reload nginx] ***
changed: [localhost]

PLAY RECAP ***
localhost  : ok=5    changed=4    unreachable=0    failed=0
             skipped=0    rescued=0    ignored=0

Gathering Facts collects facts about the machine (ansible_hostname in the template comes from here). The state of a task on each machine:

State What it means
ok the machine is already as described — nothing changed
changed the task changed the machine
skipping the task was skipped: a when condition or --check for shell
fatal … FAILED! an error; no more tasks run on this machine
UNREACHABLE! cannot connect to the machine

The summary: PLAY RECAP

PLAY RECAP is a line per machine: how many tasks ended in which state.

Field What it counts
ok tasks with and without changes — all that went through
changed tasks that changed the machine (also counted in ok)
unreachable the machine is unreachable — the tasks never ran
failed failed tasks; after the first one nothing more runs on this machine
skipped skipped tasks
rescued, ignored errors caught by rescue: or ignore_errors: true

A good repeat run is changed=0 failed=0. The exit code: 0 — all went well, 2 — a task failed on some machine, 4 — a machine is unreachable or the playbook could not be parsed (a YAML error, an unknown module), 1 — another error, for example no machines left.

The second run

text
TASK [Site directory] ***
ok: [localhost]

TASK [Page from template] ***
ok: [localhost]

TASK [Stamp] ***
changed: [localhost]

PLAY RECAP ***
localhost  : ok=4    changed=1    unreachable=0    failed=0

The file and template modules first look at the machine and change only the difference — this is idempotency. shell does not know what its command does and always reports changed. A good playbook gives changed=0 on the second run: for the task — a module instead of shell, or creates: (do not run if the file exists), or changed_when: (your own rule for what counts as a change).

Handlers

A handler is a task that runs only if another task called it with notify and that task reported changed. In the first run the template changed — nginx reloaded its config; in the second the template was ok, and there is no RUNNING HANDLER in the output.

  • Handlers run at the end of the play, after all tasks, and each one once, however many tasks called it.
  • notify looks up a handler by its name (or by listen) exactly, case-sensitively; a typo is the error ansible-playbook.
  • If a task fails, handlers called before it do not run; --force-handlers runs them anyway. To run them before the end, use a task ansible.builtin.meta: flush_handlers.

Templates and variables

The template module builds a file from a template .j2: each {{ name }} is replaced with a variable's value. It compares the result with what is on the machine and reports changed only if they differ — that is why a template and its handler give changed=0 on a repeat run.

Where the values come from:

  • the play's vars: — site_title: Demo;
  • the machine's facts from Gathering Facts — ansible_hostname (node1);
  • variables from the inventory — the machine's line or [group:vars];
  • -e site_title=Changed from the command line — above all the others.

A variable that does not exist fails the task — ansible-playbook. A default right in the template — {{ site_title | default('Demo') }}. --check --diff shows what the template would change.

Checking without changes

--check (-C) runs the playbook without changing anything; --diff (-D) prints what would change in files:

text
student@node1:~/demo$ ansible-playbook -i inventory.ini site.yml \
> --check --diff -e site_title=Changed
...
TASK [Page from template] ***
--- before: /srv/demo/index.html
+++ after: /home/student/.ansible/tmp/…/index.html.j2
@@ -1,2 +1,2 @@
-<h1>Demo</h1>
+<h1>Changed</h1>
 <p>node1</p>

changed: [localhost]

TASK [Stamp] ***
skipping: [localhost]

RUNNING HANDLER [Reload nginx] ***
changed: [localhost]

PLAY RECAP ***
localhost  : ok=4    changed=2    unreachable=0    failed=0
             skipped=1    rescued=0    ignored=0

changed here means "would change": the file stayed the same, and nobody touched nginx. shell and command are skipped in check mode — --check tells you nothing about them. A playbook for which --check --diff after a run shows no changed at all describes the machine as it is.

Options

16

The main options are in the table below; the full list is ansible-playbook --help. Without -i Ansible looks for an inventory in /etc/ansible/hosts, and the lab machine has none — pass the inventory explicitly.

Option What it does Example
-i --inventory FILE The inventory — a file listing machines and groups. ansible-playbook -i inventory.ini site.yml
-C --check A run without changes — what would change; shell and command are skipped. ansible-playbook -i inventory.ini site.yml --check
-D --diff Show the difference in files and templates, usually together with --check. ansible-playbook -i inventory.ini site.yml --check --diff
-l --limit PATTERN Only these machines or groups from the inventory. ansible-playbook -i inventory.ini site.yml -l node2
-e --extra-vars VARS Variables with the highest precedence — key=value or @file. ansible-playbook -i inventory.ini site.yml -e site_title=Demo
-b --become Run tasks through sudo — like become true in the playbook. ansible-playbook -i inventory.ini site.yml -b
-t --tags TAGS Only tasks with these tags. ansible-playbook -i inventory.ini site.yml -t page
--syntax-check Only parse the playbook; on a correct one it prints its name. ansible-playbook -i inventory.ini site.yml --syntax-check
--list-tasks The tasks in order with their tags, without a run. ansible-playbook -i inventory.ini site.yml --list-tasks
--list-hosts Which machines the playbook will go to, without a run. ansible-playbook -i inventory.ini site.yml --list-hosts
-v --verbose More detail — each task's result; -vvv adds the SSH connection. ansible-playbook -i inventory.ini site.yml -vvv
--skip-tags TAGS All tasks except those with these tags. ansible-playbook -i inventory.ini site.yml --skip-tags page
--start-at-task NAME Start at the task with this name, skipping the ones before it. ansible-playbook -i inventory.ini site.yml --start-at-task 'Stamp'
--step Ask before each task — run it, skip it, or continue without asking. ansible-playbook -i inventory.ini site.yml --step
--force-handlers Run the handlers that were called even if a task on the machine failed. ansible-playbook -i inventory.ini site.yml --force-handlers
-u --user USER Which user to log in to the machines as over SSH. ansible-playbook -i inventory.ini site.yml -u deploy

Nothing found. Try a shorter query.

Recipes

4

Check a playbook before running it

bash
ansible-playbook -i inventory.ini site.yml --syntax-check
ansible-playbook -i inventory.ini site.yml --list-hosts
ansible-playbook -i inventory.ini site.yml --list-tasks
ansible-playbook -i inventory.ini site.yml --check --diff

On a correct playbook --syntax-check prints only playbook: site.yml. --list-tasks lists the tasks in order with their tags:

text
  play #1 (local): Demo site	TAGS: []
    tasks:
      Site directory	TAGS: []
      Page from template	TAGS: [page]
      Stamp	TAGS: []

To another machine over SSH

The inventory node2.ini:

ini
[web]
node2
text
student@node1:~/demo$ ansible -i node2.ini web -m ping
node2 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

Ansible logs in over SSH under the same name as you: as student when run by student, as root under sudo. student on node1 has a key to node2 (~/.ssh/id_ed25519), and sudo there needs no password, so become: true in a playbook works on node2 too. These are the machines of a two-VM lab; a lesson machine has no node2. For another name use ansible_user=<name> on the machine's line or -u <name>.

Part of a playbook

bash
ansible-playbook -i inventory.ini site.yml -l node2     # one machine
ansible-playbook -i inventory.ini site.yml -t page      # by tag
ansible-playbook -i inventory.ini site.yml \
  --start-at-task 'Page from template'                  # from a task
ansible-playbook -i inventory.ini site.yml --step       # ask each time

With -t page the fact gathering, the tagged task and its handler ran — ok=3 changed=2.

A variable from the command line

-e site_title=Changed overrides the playbook's vars:: variables from -e have the highest precedence. Several — -e 'a=1 b=2' or a file -e @vars.yml.

Troubleshooting

9

Could not match supplied host pattern

text
[WARNING]: Unable to parse /home/student/demo/nothere.ini as an
inventory source
[WARNING]: No inventory was parsed, only implicit localhost is
available
[WARNING]: Could not match supplied host pattern, ignoring: local
...
skipping: no hosts matched

The inventory was not read: the file is missing, the path is wrong or -i was forgotten. The exit code is still 0 — check the path. If the inventory has no group named in hosts:, you get the same warning with its name.

leaves us with no hosts to target

text
[WARNING]: Could not match supplied host pattern, ignoring: web
ERROR! Specified inventory, host pattern and/or --limit leaves us
with no hosts to target.

-l names a machine or group that is not in the inventory. Compare with --list-hosts.

UNREACHABLE!

Ansible could not connect over SSH: a wrong name or address, no key, the wrong user. The SSH error text is in the msg field. Check by hand as the same user: ssh node2 true; details — -vvv. Keys — ssh-keys.

couldn't resolve module/action

text
ERROR! couldn't resolve module/action 'ansible.builtin.servce'.
This often indicates a misspelling, missing collection, or incorrect
module path.

A typo in the module name, or the collection is missing. The playbook does not start — code 4. Further down the message gives the file, line and spot of the error. Module docs — ansible-doc ansible.builtin.service.

The requested handler … was not found

text
ERROR! The requested handler 'reload nginx' was not found in either
the main handlers list nor in the listening handlers list

notify names a handler that does not exist: the name must match the handler's name letter for letter, case included. Here notify says reload nginx, and the handler is called Reload nginx.

AnsibleUndefinedVariable

text
fatal: [localhost]: FAILED! => {"changed": false, "msg":
"AnsibleUndefinedVariable: 'site_name' is undefined. ..."}

A template or task uses a variable that does not exist: a typo, or the variable is not set in vars:, the inventory or -e. A default value — {{ site_name | default('Demo') }}.

Syntax Error while loading YAML

text
Syntax Error while loading YAML.
  did not find expected key

The error appears to be in '/home/student/demo/bad-yaml.yml':
line 7, column 8, but may be elsewhere in the file ...

The indentation does not match: a task key is off by one space. YAML is strict about indentation, and tabs are not allowed in it. Further down the message shows the line with ^ under the error; often the line above it is the culprit.

Permission denied without become

text
fatal: [localhost]: FAILED! => {"changed": false, "msg": "There was
an issue creating /srv/demo2 as requested: [Errno 13] Permission
denied: b'/srv/demo2'", "path": "/srv/demo2"}

The task changes something owned by root, and the play runs as a user. become: true on the play or task means running through sudo; sudo.

Every run is changed again

shell or command without creates:/changed_when:, or a template with a variable that changes every time (a date, a random number). A handler such a task calls fires every time too.

Danger zone

2

A run without -l on a large inventory

The playbook goes to every machine in hosts:. Before the first run on production servers — --list-hosts, --check --diff and -l on one machine.

--check does not see shell

shell and command are skipped in check mode: --check shows fewer changes than a real run makes, and tasks that depend on their result can fail only in the check.

Terms

3
playbook
A YAML file with Ansible plays: on which machines (hosts) to run which tasks, in order. Run with the ansible-playbook command. Also appears in: Ansible: inventory, modules and one-off commands, ssh-keyscan
handler
A playbook task that runs only if another task called it with notify and that task changed something. It runs once at the end of the play — for example, to reload the nginx config. Also appears in: Ansible: inventory, modules and one-off commands, Docker
Jinja2 template
A .j2 file in which {{ name }} is replaced with a variable's value. The template module builds the final file on the machine from it and reports changed only if the result differs.

See also

  • ansible — inventory, modules, one-off commands.
  • ssh-keys — the key for logging in to the inventory machines.
  • ssh-client — check the connection by hand.
  • sudo — what become: true does.
  • systemctl — the services handlers restart.
  • diff-patch — how to read the --diff output.
Loading…