Skip to main content
RunBook Academy

AnsibleXI · PlaybooksPlaybooks

Anatomy of a play

Intermediate⏱ ~14 minansible-playbook

What you'll learn

  • Identify the play, not the file, as the unit of Ansible execution
  • Name the keys that define a play and state what each one controls
  • Write a playbook containing several plays with different targets
  • Explain why an unnamed task is an operational defect rather than a style one

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

A playbook is a file. A play is the thing that runs.

That distinction sounds pedantic until the first incident review, when somebody asks “what did that playbook touch?” and the honest answer is “it depends which play you mean”. A single .yml file can contain four plays aimed at four different groups, each with its own privilege escalation setting, its own variables and its own handlers. Reading the filename tells you nothing. Reading the plays tells you everything.

The play is a target plus a list of things to be true

Strip a play to its skeleton and it has two mandatory parts: a target expression and a body of work.

- name: Configure the web tier
  hosts: web
  tasks:
    - name: Ensure the vhost configuration is present
      ansible.builtin.debug:
        msg: 'placeholder for a real module'

Everything else is a modifier on those two. The keys you will use daily, and what each one actually controls:

KeyControlsFailure it causes when wrong
hostsWhich hosts this play runs againstThe entire blast radius. A typo here is the most expensive typo in the file.
becomeWhether tasks escalate privilegeTasks fail with permission errors, or worse, succeed as root when they should not have
gather_factsWhether the setup module runs firstansible_facts is empty and every templated value silently becomes undefined
varsPlay-scoped variablesValues baked into the playbook instead of inventory, invisible to anyone reading the inventory
tasksThe main body of work
handlersTasks that run only when notifiedA restart that never happens, or one that happens every night
pre_tasks / post_tasksWork that brackets the main bodyDrain and restore steps that run in the wrong place
rolesReusable task collections applied to this play
serialHow many hosts at a timeOmitted, and the play changes the whole group simultaneously
strategyTask scheduling across hostsProgress becomes hard to interpret when a run stops halfway

The full list is long and mostly rare. The playbook keywords reference is worth bookmarking rather than memorising; it also tells you which keywords are legal at play level, block level and task level, which is the question that actually comes up.

One file, several plays, different targets

A play boundary is a target change. When the next thing you need to do happens on a different set of machines, you start a new play — you do not add a when: inventory_hostname in groups['lb'] to every task.

- name: Configure the load balancer
  hosts: lb
  gather_facts: false
  tasks:
    - name: Render the backend pool
      ansible.builtin.debug:
        msg: 'the balancer is the only host that should see this'

- name: Deploy the web tier
  hosts: web
  tasks:
    - name: Apply the site-specific tuning
      ansible.builtin.debug:
        msg: 'and the web servers are the only hosts that should see this'

The two plays run in file order: the whole of play one across all its hosts, then the whole of play two. That ordering is the mechanism behind almost every drain-and-restore pattern in the course — the balancer play removes hosts from rotation before the web play touches them.

gather_facts is a decision, not a default

By default a play begins by running the setup module against every targeted host, which is a full connection plus a few seconds of data collection per host. On a 500-host play that is measurable time, and half the time the play never reads a single fact.

gather_facts: false on a play that does not need facts is free speed. gather_facts: false on a play whose template renders {{ ansible_facts['default_ipv4']['address'] }} is a run that fails with an undefined-variable error on every host, and the error will point at the template rather than at the play header.

The fact-gathering step is a real task with a real name, and it appears in output as TASK [Gathering Facts] before anything you wrote:

Read-only / Safefact gathering runs before pre_tasks
$ ansible-playbook -i inventory.ini facts.yml
PLAY [Where fact gathering sits] ***********************************************

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

TASK [First pre_task] **********************************************************
ok: [localhost] => {
  "msg": "p"
}

TASK [First task] **************************************************************
ok: [localhost] => {
  "msg": "t"
}

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

Note the ok=3. Fact gathering counts toward the recap totals, so a play with two tasks that reports ok=3 is not a mystery — it is the setup module.

Every task gets a name

This is the rule that reads like style advice and is not.

Ansible prints the task name as the run progresses, writes it into the callback output that your logging pipeline captures, and uses it as the key for --start-at-task. When a task has no name, Ansible falls back to the module it invokes. Here is the same playbook with two unnamed tasks and one named one:

Read-only / Safewhat an unnamed task looks like in the log
$ ansible-playbook -i inventory.ini unnamed.yml --list-tasks
playbook: unnamed.yml

play #1 (web): Deploy the web tier	TAGS: []
  tasks:
    ansible.builtin.debug	TAGS: []
    ansible.builtin.command	TAGS: []
    Reload the web server configuration	TAGS: []

Now imagine that file has nine ansible.builtin.command tasks, one of them failed at 03:00, and the on-call engineer is reading the log. Every line says TASK [ansible.builtin.command]. The log records that something ran and refuses to say what.

The name is the only human-readable description of intent that survives into the audit trail. Write it as a statement of the desired state, not as a description of the mechanism:

  • Good: Ensure the nginx vhost for the API is present
  • Poor: Run template module
  • Bad: no name at all

A play that is worth reviewing

Putting it together, with the parts labelled:

- name: Deploy the web tier                    # appears in the log as PLAY [...]
  hosts: web                                   # the blast radius
  become: true                                 # every task below runs as root
  gather_facts: true                           # the templates below read facts
  vars:
    app_listen_port: 8080                      # play-scoped; prefer inventory

  pre_tasks:
    - name: Remove the host from the pool
      ansible.builtin.debug:
        msg: 'drain step'

  roles:
    - webserver                                # reusable, versioned, tested

  tasks:
    - name: Ensure the application config is current
      ansible.builtin.debug:
        msg: 'main body'
      notify: Reload the web server

  post_tasks:
    - name: Return the host to the pool
      ansible.builtin.debug:
        msg: 'restore step'

  handlers:
    - name: Reload the web server
      ansible.builtin.debug:
        msg: 'runs only if something above reported changed'

Read it top to bottom and you can answer, without running anything: which hosts, with what privilege, using which variables, in what order, and what triggers the reload. That is the property the rest of this part is built on.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A single playbook file contains three plays targeting lb, web and db. What determines the order in which the three groups are touched?

  2. Q2. A play with two tasks and gather_facts left at its default reports ok=3 in the recap. What is the third ok?

  3. Q3. Which are genuine operational consequences of leaving tasks unnamed? Select all that apply.

  4. Q4. Setting become: true at play level means every task in that play, including tasks contributed by roles, will run with escalated privilege and cannot be exempted.

Passing score: 75%. Answers are checked in this browser.