Chapter 4: Playbooks — The Core
Topic 20

Playbook Anatomy

ConceptPlaybooks

A playbook is an ordered list of plays, and a play is the unit that binds a set of hosts to a set of tasks. Each play names its target with hosts:, optionally escalates privilege with become:, declares vars:, and runs an ordered tasks: list against every matched host. The play, not the task, is where "what to do" meets "to whom."

Get the play right and the rest of a playbook falls into place. The Larkspur site.yml is two plays in one file: one that configures the web tier and one that configures the db tier. This topic takes the play apart piece by piece, then shows how stacking plays in order encodes a multi-tier rollout.

The Play as the Binding Unit

A play is a dictionary with hosts: at the top, and everything beneath it — become, vars, tasks, handlers, roles — applies to exactly those hosts. That is the whole job of a play: to join a target set with the behavior to run against it. One playbook can configure the web tier and the db tier precisely because each is its own play, with its own targeting and its own task list.

site.yml — two plays, one per tier of the Larkspur stack
- name: Configure the web tier
  hosts: web
  become: yes
  vars:
    app_port: 8000
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present

- name: Configure the database tier
  hosts: db
  become: yes
  tasks:
    - name: Install PostgreSQL 16
      ansible.builtin.apt:
        name: postgresql-16
        state: present

Read top to bottom, that file says: against the web hosts as root, install nginx; then against the db hosts as root, install PostgreSQL. Two plays, two targets, two task lists — one document. Every key in a play is read relative to the hosts: line above it.

A playbook, top to bottom
playbook
site.yml — an ordered list of plays
play
hosts: + become: — targeting meets privilege
tasks
an ordered list, run in sequence per host
module
ansible.builtin.apt — the unit of work

hosts: — the Targeting Line

The hosts: line takes any inventory pattern: a single group like web, a host like db1.larkspur.io, an intersection like web:&prod, or all. Because the pattern resolves against whatever inventory you pass at run time, the same playbook reconfigures staging or production by swapping the inventory file — the plays do not change, only the hosts they match.

For Larkspur the targeting is simple and stable: play one is hosts: web and reaches web1 and web2, play two is hosts: db and reaches db1.larkspur.io. The grouping lives in the inventory; the play just names the group. That separation is what lets one site.yml serve every environment.

become: at the Play Level

Set become: yes once on the play and every task in it escalates to root through sudo. This is the normal case for a configuration play, because installing packages and writing into /etc needs root, and repeating the flag on every task is noise. The play-level setting is the default; a per-task become is the exception you reach for only when a single task needs different privilege.

Privilege escalation has its own topic later in this chapter, where become_user and become_method come in. For now the rule is: when most tasks in a play need root, declare it once at the play level and let it cover them all, rather than scattering the same flag across a dozen tasks.

vars: and Variable Scope

Variables declared in a play's vars: block are scoped to that play and visible to its tasks and templates. It is the lowest-friction place to put a value — app_port: 8000 sits right next to the tasks that use it. The trade is that the scope is exactly one play: a variable defined in the web play does not exist in the db play.

Real projects keep most variables in group_vars and host_vars rather than inline vars:, because those are shared across plays and environments and get their own chapter. Play-level vars: stays the right home for small, play-local values that nothing outside the play needs to see.

A Playbook as Ordered Plays

Plays run top to bottom, and that order is a tool, not an accident. A playbook that configures db1.larkspur.io in its first play and the web hosts in its second guarantees the database is ready before the app tier is pointed at it. The sequencing of a multi-tier rollout lives in the order you write the plays — Ansible runs them in exactly that order and infers nothing.

This is why play order is part of the design, not a formatting choice. Put the dependency first and the dependent second, and the file itself reads as the rollout plan: database, then web. Reverse them and you have written a race into the playbook that no amount of when: guarding will fix.

Common Mistakes
  • Writing one giant play with hosts: all and a pile of tasks that only apply to some hosts, then guarding each with when: — two focused plays read better and skip the wrong host entirely.
  • Putting become: yes on every individual task instead of once at the play level, bloating the playbook and inviting one task to be forgotten and fail on permission.
  • Declaring a value in one play's vars: and expecting it in a different play — play vars are scoped to their own play, so the second play sees nothing and the variable is undefined.
  • Ordering plays so the web tier is configured before the database it depends on, then chasing a connection failure that is really a sequencing bug in the file.
  • Confusing hosts: with a when: host filter — hosts: selects the set the play runs on, while when: skips a task per host within that set.
Best Practices
  • Split a playbook into one play per tier — web, db, later lb — so each play's hosts, become, and vars describe exactly that tier and nothing else.
  • Set become: yes once at the play level when most tasks need root, and drop to per-task become only for the genuine exceptions.
  • Order plays to match real dependencies — database before app tier — so the playbook itself encodes the rollout sequence and reads as the plan.
  • Keep play-level vars: for small, play-local values and push anything shared or environment-specific into group_vars and host_vars.
  • Give every play a name: so the run output labels which tier each play is working on, the same legibility a task name: buys you one level down.
Comparable tools Puppet binds resources to a node via the catalog, not an ordered play Terraform has no ordered-play concept — it builds a dependency graph Shell deploy script sections are the unstructured ancestor of plays group_vars / host_vars where real projects keep variables

Knowledge Check

What does a play bind together, and why does hosts: sit at the play level?

  • A target set of hosts and the tasks to run against them — hosts: is at the top because everything below it applies to exactly those hosts
  • A single module and the arguments passed to it, with hosts: naming the Ansible Galaxy collection that the module is published in so the play can resolve and load it at run time
  • A variable file and an inventory source, with hosts: pointing at the path of the var file to load
  • A single handler and the tasks that notify it, with hosts: naming which handler to run

Why are two focused plays (hosts: web and hosts: db) usually better than one play with hosts: all and a when: guard on each task?

  • Each play's hosts, become, and vars describe exactly one tier, and the wrong hosts are skipped entirely rather than evaluated and guarded task by task
  • A single play cannot use become on more than one task at a time, so splitting the work into one play per tier is the only way to run escalated tasks on both the web and db hosts
  • Two separate plays run in parallel against their host sets, while one combined play is forced to run strictly sequentially
  • The when: keyword is deprecated in modern playbooks and must be replaced by splitting hosts into separate plays

You declare app_port: 8000 in the web play's vars: and reference it in the db play. What happens?

  • The db play sees nothing — play vars: are scoped to their own play, so the variable is undefined there
  • The value carries forward as 8000, since every later play inherits the vars declared in the plays above it
  • Ansible raises a duplicate-variable error at parse time and stops the run before any task executes
  • The db play silently reads the missing value as the integer 0 and continues

How does the order of plays in a playbook encode a multi-tier rollout?

  • Plays run top to bottom, so putting the database play before the web play guarantees the database is ready before the app tier is configured
  • Ansible builds a dependency graph from the plays and reorders them automatically to satisfy the tier relationships, so the sequence you write the database and web plays in does not matter
  • Plays all start at the same time across the tiers, and the written order only affects which play logs its banner first
  • Run order is decided by the group ordering in the inventory file, not by the sequence in the playbook

You got correct