Playbooks, plays and tasks

Ad-hoc commands (see Ansible Basics) do one thing once. To describe how a server should be configured — and keep it that way — you write a playbook: a YAML file containing an ordered list of plays, which Ansible runs from top to bottom with ansible-playbook. It lives in version control next to the inventory, so every change to a server starts as a reviewable change to a file. (Security teams use the word "playbook" for an incident response procedure; on these pages it always means the Ansible file.)

This is site.yml, the playbook Kestrel Energy uses for the portal web servers and the database server. It is the real file from the recordings below (kestrel_root points each host at its own test folder; on real servers the paths would start at /):

- name: Configure Kestrel web servers
  hosts: web
  gather_facts: false

  tasks:
    - name: Create directories
      ansible.builtin.file:
        path: "{{ kestrel_root }}/{{ item }}"
        state: directory
        mode: "0755"
      loop:
        - etc/nginx/conf.d
        - var/www/portal

    - name: Render portal upstream
      ansible.builtin.template:
        src: portal-upstream.conf.j2
        dest: "{{ kestrel_root }}/etc/nginx/conf.d/portal-upstream.conf"
        mode: "0644"
      notify: Reload nginx

    - name: Set worker connections
      ansible.builtin.lineinfile:
        path: "{{ kestrel_root }}/etc/nginx/nginx.conf"
        regexp: '^\s*worker_connections'
        line: "    worker_connections {{ nginx_worker_connections }};"
        create: true
        mode: "0644"
      notify: Reload nginx

    - name: Install maintenance page
      ansible.builtin.copy:
        content: "<h1>Kestrel Energy portal is under maintenance</h1>\n"
        dest: "{{ kestrel_root }}/var/www/portal/maintenance.html"
        mode: "0644"

  handlers:
    - name: Reload nginx
      ansible.builtin.debug:
        msg: "nginx on {{ inventory_hostname }} would be reloaded now"

- name: Configure the database server
  hosts: db
  gather_facts: false

  tasks:
    - name: Create config directory
      ansible.builtin.file:
        path: "{{ kestrel_root }}/etc/postgresql/16/main"
        state: directory
        mode: "0755"

    - name: Allow the web servers in pg_hba.conf
      ansible.builtin.blockinfile:
        path: "{{ kestrel_root }}/etc/postgresql/16/main/pg_hba.conf"
        create: true
        mode: "0640"
        block: |
          {% for host in groups['web'] %}
          host  portal  portal_app  {{ hostvars[host].ansible_host }}/32  scram-sha-256
          {% endfor %}

    - name: Enable nightly billing export (production only)
      ansible.builtin.copy:
        content: "30 2 * * * billing /opt/kestrel/bin/export-billing\n"
        dest: "{{ kestrel_root }}/etc/cron.d/billing-export"
        mode: "0644"
      when: kestrel_env == "prod"

Plays

A play is one entry in the playbook's top-level list: it names the hosts it targets (hosts:, any host pattern), the settings for them (gather_facts, become, vars) and the tasks and handlers to run there. site.yml has two plays: one for the group web, one for db. Plays run one after the other; within a play, Ansible runs each task on all targeted hosts in parallel before it moves to the next task.

Tasks

A task is one call to one module, with a human-readable name and the module's arguments, plus optional keywords that control it (loop, when, notify, register, become, tags). Write module names in full (ansible.builtin.copy; see Roles and Collections) and give every task a name that says what state it ensures, because that name is what appears in the output and in a reviewer's head.

YAML details matter here: indentation decides what belongs to what, and file modes must be quoted strings (mode: "0644"), because an unquoted 644 is read as a decimal number and produces very wrong permissions (see Configuration Languages: HCL, YAML and JSON).

The playbook site.yml contains two plays that run in order. Play 1 targets hosts in group web and has four tasks: create directories, render portal upstream, set worker connections and install maintenance page. Tasks 2 and 3 notify the handler Reload nginx, which runs once per host at the end of the play. Play 2 targets group db with three tasks: create config directory, the pg_hba.conf block, and the billing export, which runs only when kestrel_env is prod. site.yml (playbook) Play 1 · hosts: web 1 Create directories 2 Render portal upstream 3 Set worker connections 4 Install maintenance page handlers: Reload nginx once per host, at end of play, if notified tasks run in order, each on all web hosts Play 2 · hosts: db 1 Config directory 2 pg_hba.conf 3 Billing export when: prod
Plays run in order; tasks run in order within a play; a handler waits until the end of its play and runs at most once per host.

Running a playbook

Check the syntax first; it parses the YAML and the playbook structure without contacting any host:

$ cd /home/amara/kestrel-infra/ansible
$ ansible-playbook site.yml --syntax-check

playbook: site.yml

Then the first real run, on hosts that do not have any of this configuration yet:

$ ansible-playbook site.yml

PLAY [Configure Kestrel web servers] *******************************************

TASK [Create directories] ******************************************************
changed: [web02] => (item=etc/nginx/conf.d)
changed: [web01] => (item=etc/nginx/conf.d)
changed: [web02] => (item=var/www/portal)
changed: [web01] => (item=var/www/portal)

TASK [Render portal upstream] **************************************************
changed: [web02]
changed: [web01]

TASK [Set worker connections] **************************************************
changed: [web01]
changed: [web02]

TASK [Install maintenance page] ************************************************
changed: [web01]
changed: [web02]

RUNNING HANDLER [Reload nginx] *************************************************
ok: [web01] => {
    "msg": "nginx on web01 would be reloaded now"
}
ok: [web02] => {
    "msg": "nginx on web02 would be reloaded now"
}

PLAY [Configure the database server] *******************************************

TASK [Create config directory] *************************************************
changed: [db01]

TASK [Allow the web servers in pg_hba.conf] ************************************
changed: [db01]

TASK [Enable nightly billing export (production only)] *************************
skipping: [db01]

PLAY RECAP *********************************************************************
db01                       : ok=2    changed=2    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   
web01                      : ok=5    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web02                      : ok=5    changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Each task prints one line per host (per loop item for a loop): changed when the module had to act, ok when the host was already in the desired state, skipping when a condition was false, and fatal: … FAILED! or UNREACHABLE! when something went wrong. The order of web01 and web02 lines varies from run to run, because the hosts are handled in parallel.

The handler is a debug message in this test setup; on the real servers it would reload nginx. The billing export was skipped because these hosts are the test environment (kestrel_env: test). The files the play wrote, on web02 and db01:

# Ansible managed
upstream kestrel_portal {
    server 192.0.2.11:8080;
    server 192.0.2.12:8080;
}
# BEGIN ANSIBLE MANAGED BLOCK
host  portal  portal_app  192.0.2.11/32  scram-sha-256
host  portal  portal_app  192.0.2.12/32  scram-sha-256
# END ANSIBLE MANAGED BLOCK

The upstream file comes from a Jinja2 template and the pg_hba.conf lines from a loop over the inventory group web: add a web03 to the inventory and both files gain a line on the next run. How that works is the subject of Variables, Facts and Templates.

Reading the play recap

The PLAY RECAP is the summary Ansible prints at the end of every run: one line per host with seven counters. Read it before anything else, the way you read the summary line of a Terraform plan.

Counter Counts the tasks that…
ok ran successfully, whether or not they changed anything
changed changed something on the host
unreachable could not connect (the host is dropped from the run)
failed failed (the host is dropped from the rest of the play)
skipped did not run because a condition was false
rescued failed inside a block whose rescue section then ran
ignored failed, but had ignore_errors: true

For web01, ok=5 is the four tasks plus the handler, and changed=4 the four tasks that changed something (a loop counts as one task). ansible-playbook exits with exit status 0 when no host failed, 2 when a task failed on some host, and 4 when hosts were unreachable, which is what a pipeline acts on.

Idempotency: the second run

Run the same playbook again, without changing anything:

$ ansible-playbook site.yml

PLAY [Configure Kestrel web servers] *******************************************

TASK [Create directories] ******************************************************
ok: [web01] => (item=etc/nginx/conf.d)
ok: [web02] => (item=etc/nginx/conf.d)
ok: [web01] => (item=var/www/portal)
ok: [web02] => (item=var/www/portal)

TASK [Render portal upstream] **************************************************
ok: [web01]
ok: [web02]

TASK [Set worker connections] **************************************************
ok: [web02]
ok: [web01]

TASK [Install maintenance page] ************************************************
ok: [web02]
ok: [web01]

PLAY [Configure the database server] *******************************************

TASK [Create config directory] *************************************************
ok: [db01]

TASK [Allow the web servers in pg_hba.conf] ************************************
ok: [db01]

TASK [Enable nightly billing export (production only)] *************************
skipping: [db01]

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

Every task is ok, changed=0 on every host, and the handler did not run because nothing notified it. This is idempotence: running the playbook once or ten times leaves the hosts in the same state. It is what makes it safe to run the playbook after every merge, on a schedule, or "just to be sure" during an incident — and it turns changed into information: on a host that should already be correct, a change means someone or something altered it (see diff mode below).

The idempotency comes from the modules. file, template, lineinfile, blockinfile and copy all compare the desired state with the actual state and act only on the difference. The difference with a shell habit is easy to model. Here an admin's hand-tuned line is "fixed" twice by each method:

def append_line(lines, line):
    """Like `echo line >> nginx.conf`: always adds the line."""
    return lines + [line], True


def ensure_line(lines, prefix, line):
    """Like ansible.builtin.lineinfile with a regexp: fix the matching line, or add it."""
    for number, current in enumerate(lines):
        if current.strip().startswith(prefix):
            if current == line:
                return lines, False  # already right: report ok
            return lines[:number] + [line] + lines[number + 1:], True
    return lines + [line], True


hand_tuned = ["    worker_connections 4096;  # tuned by hand"]
wanted = "    worker_connections 1024;"
methods = {
    "echo >>": lambda lines: append_line(lines, wanted),
    "lineinfile": lambda lines: ensure_line(lines, "worker_connections", wanted),
}

for name, method in methods.items():
    nginx_conf = hand_tuned
    for run in (1, 2):
        nginx_conf, changed = method(nginx_conf)
        print(f"{name:10} run {run}: changed={changed}, {len(nginx_conf)} line(s)")
    print("    " + " | ".join(line.strip() for line in nginx_conf))
echo >>    run 1: changed=True, 2 line(s)
echo >>    run 2: changed=True, 3 line(s)
    worker_connections 4096;  # tuned by hand | worker_connections 1024; | worker_connections 1024;
lineinfile run 1: changed=True, 1 line(s)
lineinfile run 2: changed=False, 1 line(s)
    worker_connections 1024;

Appending grows the file on every run and leaves the conflicting old line in place; the state-based version converges on one correct line and then reports "nothing to do".

Making commands idempotent

command and shell cannot compare states: they run, and report changed. Three tools tell them what "already done" or "changed" means:

- name: Initialize the billing export schema (once)
  ansible.builtin.command:
    cmd: /opt/kestrel/bin/export-billing --init-schema
    creates: /var/lib/kestrel/billing/.schema-initialized   # skip if this file exists

- name: Check the PostgreSQL client version
  ansible.builtin.command: psql --version
  register: psql_version
  changed_when: false                                      # reading never changes anything

- name: Reload the portal configuration
  ansible.builtin.command: /opt/kestrel/bin/portalctl reload
  register: reload
  changed_when: "'reloaded' in reload.stdout"               # changed only if it says so
  • creates: (and its opposite removes:) skips the command when a file exists (or is absent).
  • changed_when is a task keyword with a condition that decides whether the task reports changed, overriding what the module said: false for read-only commands, or an expression on the registered result.
  • failed_when does the same for failure, for commands whose exit status does not mean what Ansible assumes.

Handlers

A handler is a task that runs only when another task notifies it and that task reports changed. Handlers are listed under handlers:, are notified by name with notify:, and run after the play's tasks (strictly: after each of pre_tasks, roles/tasks and post_tasks), once per host however many tasks notified them. The classic use is "restart or reload the service when its configuration changed": in the first run above, two tasks notified Reload nginx and it ran once per web server; in the second run nothing changed, so nginx would not have been touched.

Two details catch people out:

  • Handlers wait until the end of the play. If a later task needs the reload to have happened (a smoke test against the new configuration), run the pending handlers at that point:
- name: Apply pending handlers before the smoke test
  ansible.builtin.meta: flush_handlers

- name: Check that the portal answers
  ansible.builtin.uri:
    url: "http://{{ ansible_host }}:8080/health"
    status_code: 200
  • If a task fails on a host after a handler was notified, the host leaves the play and the handler does not run there — the new configuration is on disk, but nginx still runs the old one. The next run will not notify it again, because the file is already correct. --force-handlers (or force_handlers: true on the play) runs notified handlers even after a failure.

Check mode and diff mode

Check mode (--check) runs a playbook without changing anything: each module that supports it reports what it would do. Diff mode (--diff) makes modules that support it show the change itself, as a before/after diff of a file. Together they are Ansible's dry run. Here Ravi Kumar has changed the portal port to 8081 in a variable file and checks the effect on one host first:

$ ansible-playbook site.yml --check --diff --limit web01

PLAY [Configure Kestrel web servers] *******************************************

TASK [Create directories] ******************************************************
ok: [web01] => (item=etc/nginx/conf.d)
ok: [web01] => (item=var/www/portal)

TASK [Render portal upstream] **************************************************
--- before: /home/amara/kestrel-infra/ansible/hosts/web01/etc/nginx/conf.d/portal-upstream.conf
+++ after: /home/amara/.ansible/tmp/ansible-local-24822247e1m8vun/tmpwvgc202d/portal-upstream.conf.j2
@@ -1,5 +1,5 @@
 # Ansible managed
 upstream kestrel_portal {
-    server 192.0.2.11:8080;
-    server 192.0.2.12:8080;
+    server 192.0.2.11:8081;
+    server 192.0.2.12:8081;
 }

changed: [web01]

TASK [Set worker connections] **************************************************
ok: [web01]

TASK [Install maintenance page] ************************************************
ok: [web01]

RUNNING HANDLER [Reload nginx] *************************************************
ok: [web01] => {
    "msg": "nginx on web01 would be reloaded now"
}

PLAY [Configure the database server] *******************************************
skipping: no hosts matched

PLAY RECAP *********************************************************************
web01                      : ok=5    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

The diff shows exactly which lines would change, and the recap says changed=1 — but the file on web01 still contains port 8080 afterward; nothing was written. (The handler "ran" because a debug task changes nothing either way; a real service-reload handler is also simulated in check mode.) A check run's changed count is a prediction, not a result.

Diff mode is just as useful on a real run, where it documents what was changed. Someone had edited nginx.conf on web01 by hand during a busy evening (worker_connections 4096); the next normal run with --diff shows the drift and puts the managed value back:

$ ansible-playbook site.yml --diff --limit web01
…
TASK [Set worker connections] **************************************************
--- before: /home/amara/kestrel-infra/ansible/hosts/web01/etc/nginx/nginx.conf (content)
+++ after: /home/amara/kestrel-infra/ansible/hosts/web01/etc/nginx/nginx.conf (content)
@@ -1 +1 @@
-    worker_connections 4096;  # tuned by hand
+    worker_connections 1024;

changed: [web01]
…
PLAY RECAP *********************************************************************
web01                      : ok=5    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

That is configuration management repairing configuration drift. Whether repairing it was right is a separate question: if the hand-tuning fixed a real problem, the fix belongs in the variable files, through a reviewed change, before the next run removes it.

Conditionals with when

when: runs a task only on hosts where an expression is true; elsewhere the task is skipping, as the billing export task was. The value is a bare Jinja2 expression — no {{ }} around it — and a list of conditions means "all of them":

- name: Enable the nightly billing export
  ansible.builtin.copy:
    content: "30 2 * * * billing /opt/kestrel/bin/export-billing\n"
    dest: /etc/cron.d/billing-export
    mode: "0644"
  when:
    - kestrel_env == "prod"
    - inventory_hostname in groups['db']

- name: Show the maintenance page
  ansible.builtin.file:
    src: /var/www/portal/maintenance.html
    dest: /var/www/portal/index.override.html
    state: link
  when: portal_maintenance | bool      # "true", "yes" and true all become true

Since ansible-core 2.19, a condition must produce a real boolean. Older versions accepted anything "truthy", which hid real mistakes; now a non-boolean result is an error, for example "Conditional result was 'localhost' of type 'str', which evaluates to True. Conditionals must have a boolean result." A template inside an expression (when: count + {{ extra }} > 2) is a syntax error ("Template delimiters are not supported in expressions"), and a condition wrapped entirely in {{ }} produces a deprecation warning. Write when: portal_maintenance | bool, not when: "{{ portal_maintenance }}".

Loops

loop: runs a task once per item in a list, with the current item available as item; the output shows one line per item (changed: [web01] => (item=var/www/portal)). A list of dictionaries handles several values per item, and loop_control: label keeps the output readable:

- name: Create the Kestrel service accounts
  ansible.builtin.user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    shell: /usr/sbin/nologin
    system: true
  loop:
    - { name: portal, groups: www-data }
    - { name: billing, groups: postgres }
  loop_control:
    label: "{{ item.name }}"
  become: true

Older playbooks use with_items:, with_dict: and similar; they still work, but loop: with filters is the current style. Prefer a module that takes a list itself where one exists: the apt module installs a whole list of packages in one transaction, which is faster than looping over it.

Failures, ignore_errors and blocks

When a task fails on a host, Ansible prints the error, removes that host from the rest of the play, and carries on with the others. This pre-deployment check reads the upstream file, counts the backends and requires at least three (there are two, so it fails, but ignore_errors lets the play continue); then a task reads a file that does not exist, on web01 only:

- name: Pre-deployment checks for the portal
  hosts: web
  gather_facts: false

  tasks:
    - name: Read the upstream file
      ansible.builtin.command: cat {{ kestrel_root }}/etc/nginx/conf.d/portal-upstream.conf
      register: upstream
      changed_when: false

    - name: Show what was registered
      ansible.builtin.debug:
        var: upstream.stdout_lines

    - name: Count backend servers
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} has {{ upstream.stdout_lines | select('search', 'server ') | list | length }} backends"

    - name: Fail when fewer than three backends are configured
      ansible.builtin.command: cat {{ kestrel_root }}/etc/nginx/conf.d/portal-upstream.conf
      register: check
      changed_when: false
      failed_when: check.stdout_lines | select('search', 'server ') | list | length < 3
      ignore_errors: true

    - name: Read a file that does not exist
      ansible.builtin.command: cat {{ kestrel_root }}/etc/kestrel/feature-flags.yml
      changed_when: false
      when: inventory_hostname == "web01"

    - name: This task never runs on web01
      ansible.builtin.debug:
        msg: "Still going on {{ inventory_hostname }}"
$ ansible-playbook checks.yml
…
TASK [Fail when fewer than three backends are configured] **********************
[ERROR]: Task failed: Action failed.
Origin: /home/amara/kestrel-infra/ansible/checks.yml:19:7

17         msg: "{{ inventory_hostname }} has {{ upstream.stdout_lines | select('search', 'server ') | list | length ...
18
19     - name: Fail when fewer than three backends are configured
         ^ column 7

fatal: [web01]: FAILED! => {"changed": false, "cmd": ["cat", "/home/amara/kestrel-infra/ansible/hosts/web01/etc/nginx/conf.d/portal-upstream.conf"], "delta": "0:00:00.003789", "end": "2026-10-06 18:05:17.114041", "failed_when_result": true, "msg": "", "rc": 0, "start": "2026-10-06 18:05:17.110252", "stderr": "", "stderr_lines": [], "stdout": "# Ansible managed\nupstream kestrel_portal {\n    server 192.0.2.11:8080;\n    server 192.0.2.12:8080;\n}", "stdout_lines": ["# Ansible managed", "upstream kestrel_portal {", "    server 192.0.2.11:8080;", "    server 192.0.2.12:8080;", "}"]}
...ignoring
…
TASK [Read a file that does not exist] *****************************************
skipping: [web02]
[ERROR]: Task failed: Module failed: non-zero return code
Origin: /home/amara/kestrel-infra/ansible/checks.yml:26:7

24       ignore_errors: true
25
26     - name: Read a file that does not exist
         ^ column 7

fatal: [web01]: FAILED! => {"changed": false, "cmd": ["cat", "/home/amara/kestrel-infra/ansible/hosts/web01/etc/kestrel/feature-flags.yml"], "delta": "0:00:00.002898", "end": "2026-10-06 18:05:17.458042", "msg": "non-zero return code", "rc": 1, "start": "2026-10-06 18:05:17.455144", "stderr": "cat: /home/amara/kestrel-infra/ansible/hosts/web01/etc/kestrel/feature-flags.yml: No such file or directory", "stderr_lines": ["cat: /home/amara/kestrel-infra/ansible/hosts/web01/etc/kestrel/feature-flags.yml: No such file or directory"], "stdout": "", "stdout_lines": []}

TASK [This task never runs on web01] *******************************************
ok: [web02] => {
    "msg": "Still going on web02"
}

PLAY RECAP *********************************************************************
web01                      : ok=4    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=1   
web02                      : ok=5    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=1   

Since ansible-core 2.19 a failure starts with an [ERROR]: line and an Origin: line that points to the file, line and column of the task, followed by the classic fatal: [host]: FAILED! => {…} with the module's full result. Read msg, rc and stderr first: here rc 1 and "No such file or directory". The ignored failure is marked ...ignoring and counted as ignored; the real one removed web01 from the play, so the last task ran on web02 only, and the command exited with status 2.

block, rescue and always

A block groups tasks; rescue runs only if a task in the block failed (like except in Python), and always runs either way (like finally). Kestrel uses it to roll back a release marker when the migration check fails:

- name: Deploy meter-api release
  hosts: web
  gather_facts: false
  vars:
    release: "2026.10.2"

  tasks:
    - name: Deploy with automatic rollback
      tags: [deploy]
      block:
        - name: Write release marker
          ansible.builtin.copy:
            content: "{{ release }}\n"
            dest: "{{ kestrel_root }}/var/www/portal/RELEASE"
            mode: "0644"

        - name: Run database migration check
          ansible.builtin.command: cat {{ kestrel_root }}/var/www/portal/migrations-ok
          changed_when: false

      rescue:
        - name: Roll back release marker
          ansible.builtin.copy:
            content: "2026.10.1\n"
            dest: "{{ kestrel_root }}/var/www/portal/RELEASE"
            mode: "0644"

        - name: Report the failure
          ansible.builtin.debug:
            msg: "Rolled back {{ inventory_hostname }}: {{ ansible_failed_result.stderr }}"

      always:
        - name: Log the attempt
          ansible.builtin.lineinfile:
            path: "{{ kestrel_root }}/var/www/portal/deploy.log"
            line: "attempted {{ release }}"
            create: true
            mode: "0644"

    - name: Show final release
      tags: [report]
      ansible.builtin.command: cat {{ kestrel_root }}/var/www/portal/RELEASE
      register: final
      changed_when: false

    - name: Print final release
      tags: [report]
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} runs {{ final.stdout }}"

Only web02 had the migrations-ok file:

$ ansible-playbook deploy.yml
…
TASK [Run database migration check] ********************************************
[ERROR]: Task failed: Module failed: non-zero return code
…
ok: [web02]

TASK [Roll back release marker] ************************************************
changed: [web01]

TASK [Report the failure] ******************************************************
ok: [web01] => {
    "msg": "Rolled back web01: cat: /home/amara/kestrel-infra/ansible/hosts/web01/var/www/portal/migrations-ok: No such file or directory"
}

TASK [Log the attempt] *********************************************************
changed: [web02]
changed: [web01]
…
TASK [Print final release] *****************************************************
ok: [web01] => {
    "msg": "web01 runs 2026.10.1"
}
ok: [web02] => {
    "msg": "web02 runs 2026.10.2"
}

PLAY RECAP *********************************************************************
web01                      : ok=6    changed=3    unreachable=0    failed=0    skipped=0    rescued=1    ignored=0   
web02                      : ok=5    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

web01 failed inside the block, ran the rescue and continued: rescued=1, failed=0, exit status 0. That is right when the rescue really restores a safe state; when it only reports, re-raise the failure (for example with a final ansible.builtin.fail task in rescue) so the run does not look successful.

Running part of a playbook

A few options limit what a run touches — the same idea as a small blast radius:

  • --limit web01 (-l) narrows the hosts to a subset of what the plays target: change one server, check it, then the rest.
  • --tags report runs only tasks with that tag; --skip-tags does the opposite; --list-tags and --list-tasks show what exists without running anything.
  • --start-at-task "…" skips everything before the named task, and --step asks before each task.
  • -v adds each task's full result to the output; -vvv also shows connections and commands (see what happens when a module runs).
$ ansible-playbook deploy.yml --list-tasks
$ ansible-playbook deploy.yml --start-at-task "Print final release" --limit web02

playbook: deploy.yml

  play #1 (web): Deploy meter-api release	TAGS: []
    tasks:
      Write release marker	TAGS: [deploy]
      Run database migration check	TAGS: [deploy]
      Show final release	TAGS: [report]
      Print final release	TAGS: [report]

PLAY [Deploy meter-api release] ************************************************

TASK [Print final release] *****************************************************
[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'final' is undefined
…
fatal: [web02]: FAILED! => {"msg": "Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'final' is undefined"}

PLAY RECAP *********************************************************************
web02                      : ok=0    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   

A real trap: starting at "Print final release" skipped the task that registers final, so the variable does not exist. Started one task earlier (--start-at-task "Show final release") the same run succeeds. Partial runs are for recovery and testing; the normal run is the whole playbook, because only the whole playbook is guaranteed to be consistent.