Skip to main content
RunBook Academy

AnsibleXI · PlaybooksPlaybooks

A playbook someone else can operate

Intermediate⏱ ~16 minansible-playbook

What you'll learn

  • Judge a playbook by whether a colleague could operate it at 03:00
  • Recognise the structural smells that predict an unoperable playbook
  • Decide when a task list has earned promotion to a role
  • Keep environment-specific values out of the playbook and in inventory

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.

The test for a playbook is not whether it works. It is whether the person on call — who did not write it, has not read it, and is looking at it for the first time during an incident — can decide in two minutes whether running it is safe.

That is a different property from correctness, and it is the one that degrades silently.

What “operable” means, concretely

A colleague opens your playbook at 03:00. They need five answers, fast:

  1. Which hosts? Answered by the hosts: line of each play, in isolation, without cross-referencing anything.
  2. What will it change? Answered by the task names, read in order.
  3. Is it safe to run twice? Answered by whether the tasks are declarative module calls or shell lines.
  4. What does it need that might be missing? Answered by whether required variables are declared and asserted, or assumed.
  5. What happens if it stops halfway? Answered by whether restore steps are in post_tasks (which a failed host skips) or in a block’s always (which it does not).

Every structural rule below exists to keep one of those five answers readable.

One purpose per play

A play should do one thing to one class of machine. When you find yourself writing “and” in the play name — Deploy the app and rotate the certificates and clean up old releases — you have three plays, or one play that three different people will want to run separately.

The cost of merging them is paid at 03:00, when someone needs only the certificate rotation and the only way to get it is to run all three or to copy tasks into a new file during an incident.

Keep logic out of the file

A playbook describes what should be true. The moment it starts deciding which truth applies, based on branching, it has become a program in a language with no functions, no tests and no debugger.

The specific smells:

SmellWhat it usually meansWhere the logic belongs
Conditionals stacked four deepThe play targets too broad a groupThe hosts: line, or group membership in inventory
when: inventory_hostname == 'web03.example.com'A host-specific exceptionhost_vars/web03.example.com.yml
A set_fact chain deriving a value from three othersA computed configuration valueA group var, or a role default
Long inline Jinja with nested filtersA rendering decisionA template file, or a filter with a name
A 400-line tasks: blockSeveral roles that have not been extractedRoles
A play targeting all with per-task guardsScope expressed as filteringSeparate plays per target

Ansible’s conditional syntax works perfectly well. The objection is not that when: is bad — it is that a decision encoded in a conditional is invisible to --list-hosts, invisible to the inventory, and visible only to someone reading every line of the file.

Declare what you require

A playbook that needs app_version and does not say so fails at task 9 with a Jinja error naming a template. A playbook that asserts its inputs fails at task 1 with a sentence a human wrote:

  pre_tasks:
    - name: Require the variables this play cannot run without
      ansible.builtin.assert:
        that:
          - app_version is defined
          - db_host is defined
          - deploy_user is defined
        fail_msg: >-
          Set app_version, db_host and deploy_user in group_vars for this
          environment. See docs/deploy.md.

This costs four lines and one task. It converts every “why did this fail on staging” conversation into a message that answers itself. The guardrails part builds this into a full precondition discipline; the habit starts here.

When a task list should become a role

Not every task list needs to be a role. Premature extraction produces a roles/ directory full of single-use roles with one variable each, which is its own maintenance problem.

The signals that a task list has earned promotion:

  • It is used by more than one playbook. The strongest signal, and the only one that is purely objective.
  • It has its own variables with sensible defaults. A role’s defaults/main.yml is a real interface; a task list has no way to declare one.
  • It has files and templates. Roles resolve files/ and templates/ relative to themselves, which a bare task list cannot do cleanly.
  • It is long enough that you scroll past it. Roughly 40–50 tasks, though the real test is whether you have started using comments as section headers.
  • Someone else would plausibly want it. Not “would publish it to Galaxy” — just “the database team would use this”.

The signals that it should stay a task list:

  • It is used once, by one playbook, and always will be.
  • It is glue: three tasks that connect two roles.
  • Extracting it would mean inventing five variables to parameterise things nobody varies.

Reading a playbook the way a reviewer should

The review order is not top to bottom. It is:

  1. Every hosts: line. How much of the estate is in scope?
  2. Every become:. What runs as root, and does it need to?
  3. --list-tasks. Does the resolved order match the intent?
  4. Every shell and command task. Are they fenced with creates or given accurate change reporting?
  5. The variables. Are environment-specific values in inventory, or baked in?
  6. The restore path. If this stops halfway, what is left in a partial state, and does anything undo it?

Only then read the tasks in order.

Read-only / Safestep 3 of the review, done mechanically
$ ansible-playbook -i inventory.ini site.yml --list-tasks
playbook: site.yml

play #1 (lb): Configure the load balancer	TAGS: [lb]
  tasks:
    Render the backend pool	TAGS: [config, lb]

play #2 (web): Deploy the web tier	TAGS: []
  tasks:
    Take the host out of the load balancer pool	TAGS: []
    webserver : R1 role task, notifies H-role	TAGS: []
    Apply the site-specific tuning	TAGS: []
    Return the host to the load balancer pool	TAGS: []

play #3 (db): Configure the database tier	TAGS: [db]
  tasks:
    Check replication lag	TAGS: [db, health]

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play named Deploy the app, rotate certificates and clean up old releases works correctly. What is the strongest argument for splitting it into three plays?

  2. Q2. Where should the value of db_host live for a playbook that runs against both staging and production?

  3. Q3. Which are genuine signals that a task list has earned promotion to a role? Select all that apply.

  4. Q4. A pre_tasks assertion that the required variables are defined turns a late, confusing failure into an early, self-explanatory one.

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