Playbook Anatomy
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.
- 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.
site.yml — an ordered list of playshosts: + become: — targeting meets privilegeansible.builtin.apt — the unit of workhosts: — 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.
- Writing one giant play with
hosts: alland a pile of tasks that only apply to some hosts, then guarding each withwhen:— two focused plays read better and skip the wrong host entirely. - Putting
become: yeson 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 awhen:host filter —hosts:selects the set the play runs on, whilewhen:skips a task per host within that set.
- Split a playbook into one play per tier —
web,db, laterlb— so each play'shosts,become, andvarsdescribe exactly that tier and nothing else. - Set
become: yesonce at the play level when most tasks need root, and drop to per-taskbecomeonly 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 intogroup_varsandhost_vars. - Give every play a
name:so the run output labels which tier each play is working on, the same legibility a taskname:buys you one level down.
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
becomeon 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
0and 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