Skip to content

4. Playbooks, plays and handlers

A playbook is a YAML list of plays. A play maps a set of hosts to a list of tasks. That is the whole structure, and everything else is a keyword on one of those two things.

---
- name: Configure the web tier
hosts: webservers
become: true
vars:
nginx_worker_processes: auto
pre_tasks:
- name: Check we are on a supported release
ansible.builtin.assert:
that: ansible_facts['distribution_major_version'] | int >= 22
roles:
- common
- nginx
tasks:
- name: Deploy the site configuration
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
owner: root
mode: '0644'
validate: 'nginx -t -c %s'
notify: Reload nginx
post_tasks:
- name: Wait for the port to answer
ansible.builtin.wait_for:
port: 443
timeout: 30
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded

This is the part people get wrong, and it is fixed:

1. gather_facts (unless disabled)
2. pre_tasks
3. handlers notified by pre_tasks ← flushed here
4. roles
5. tasks
6. post_tasks
7. handlers notified by roles/tasks/post_tasks

Two things to notice.

Handlers are flushed after pre_tasks, not only at the end. This is why pre_tasks is the right place for anything that must complete before roles begin — draining a load balancer, stopping a service.

Roles run before tasks, regardless of where you write them in the file. A tasks: block written above roles: in the YAML still runs after the roles. If you want tasks before roles, they go in pre_tasks. This surprises people exactly once.

- hosts: webservers
become: true
gather_facts: true # default; false is faster
serial: 2 # rolling — chapter 11
strategy: linear # default; also free, host_pinned
order: inventory # also sorted, reverse_sorted,
# reverse_inventory, shuffle
any_errors_fatal: false # stop everything if any host fails
max_fail_percentage: 25 # abort if more than 25% fail
vars_files:
- vars/common.yml
environment: # env vars for every task in the play
http_proxy: http://proxy.example.com:3128
check_mode: false
tags: [web]

strategy is worth a paragraph because it changes the shape of a run.

linear (default)
Every host runs task 1. Ansible waits for ALL of
them. Then every host runs task 2.
→ predictable, and as slow as the slowest host
at every single step
free
Each host races ahead through the task list as
fast as it can, independently.
→ much faster on heterogeneous fleets
→ but hosts are at different tasks at the same
moment, which breaks anything that assumes
ordering across hosts
host_pinned
Like free, but a worker sticks with one host
until it finishes.

Use linear unless you have measured a reason not to. free is genuinely faster on a large mixed fleet and it quietly invalidates any orchestration that depends on all hosts reaching the same point together — which is most of chapter 11.

A task is a name, a module, and its arguments, plus optional keywords.

- name: Ensure the app user exists
ansible.builtin.user:
name: app
uid: 1500
shell: /usr/sbin/nologin
state: present
become: true
when: ansible_facts['os_family'] == 'Debian'
tags: [users]
register: app_user_result

Always write name:. It is optional and omitting it is a mistake — the name is what appears in output, what --start-at-task matches, what --list-tasks shows, and what tells the next person what the task was for. An unnamed task prints its module and arguments, which is noise.

Write names as statements of desired state, not actions: “Ensure nginx is installed”, not “Install nginx”. The task may well do nothing, and a log full of “Install nginx → ok” reads oddly. This is a small habit that makes output much easier to read.

Every task produces one of four outcomes:

ok the module ran and nothing needed doing
changed the module ran and altered the system
failed the module returned an error
skipped a `when` condition was false

Plus unreachable, which is different in kind — the host could not be contacted at all, so nothing was attempted. Unreachable hosts are removed from the play for its remaining tasks.

register captures the module’s full JSON result into a variable, which is how tasks talk to each other:

- name: Read the current version
ansible.builtin.command: /opt/app/bin/version
register: version_check
changed_when: false
- name: Upgrade if it is old
ansible.builtin.include_tasks: upgrade.yml
when: version_check.stdout is version('2.4.0', '<')

Note changed_when: false on the first task. command always reports changed, because it cannot know better. Chapter 5 is about that.

A handler is a task that runs only if something notified it, and only once, at the end of the play.

tasks:
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Reload nginx
- name: Deploy site config
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded

Both templates changed? nginx reloads once. Neither changed? nginx does not reload at all. That is exactly right, and it is why handlers exist.

1. Notification requires changed. A task that reports ok notifies nothing. This is the mechanism working correctly, and it is also the commonest confusion: “my handler did not run” almost always means “the task did not change anything”.

2. Handlers run at the end of the play, not where they are notified. If you need a service restarted before a later task in the same play, you must force it:

- name: Flush handlers now
ansible.builtin.meta: flush_handlers

3. Handlers run in the order they are defined, not the order they were notified. Two handlers notified in the order B, A run in the order A, B if that is how the handlers: section lists them. To sequence handlers, have one handler notify another.

4. Handlers do not run if the play fails. This is the rule that bites hardest. Host fails at task 12, the handler notified at task 3 never runs, and you are left with a config file written and the service still running the old one — the worst possible half-state.

Terminal window
ansible-playbook site.yml --force-handlers
# or, permanently:
- hosts: webservers
force_handlers: true

Consider force_handlers: true on plays where a half-applied config is worse than a failure, which is most of them.

5. Handlers are per-play. A handler notified in one play does not exist in the next. Notifications do not cross the play boundary.

tasks:
- ansible.builtin.template:
src: a.conf.j2
dest: /etc/app/a.conf
notify: Restart the app stack
handlers:
- name: Restart the API
ansible.builtin.service: {name: api, state: restarted}
listen: Restart the app stack
- name: Restart the worker
ansible.builtin.service: {name: worker, state: restarted}
listen: Restart the app stack

One notification, several handlers. This is how roles expose a stable notification name without callers needing to know how many services are behind it — and it is the right way for a role to publish its handlers.

The template and copy modules take a validate argument, and it deserves more use than it gets:

- ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: 'nginx -t -c %s'

Ansible writes to a temporary file, runs the command with %s replaced by that path, and only moves it into place if the command succeeds. A syntax error in your template becomes a failed task rather than a service that will not start on next reload.

validate: 'nginx -t -c %s'
validate: 'visudo -cf %s' ← especially this one
validate: 'sshd -t -f %s'
validate: 'named-checkconf %s'
validate: '/usr/bin/env python3 -c "import json,sys; json.load(open(sys.argv[1]))" %s'

visudo -cf %s on any sudoers file is not optional. A broken sudoers file locks everyone out of privilege escalation on that machine, including Ansible, and fixing it requires console access.

---
- name: Base configuration for every machine
hosts: all
become: true
roles: [common, ntp, monitoring]
- name: Database tier
hosts: databases
become: true
serial: 1 # one at a time, always
roles: [postgresql]
- name: Web tier
hosts: webservers
become: true
serial: '25%'
roles: [nginx, app]

One playbook, several plays, each targeting a different group. ansible-playbook site.yml configures everything; --limit databases does one tier; --tags monitoring does one concern across all of them. This shape scales from three machines to three thousand without changing.

  • A playbook is a list of plays; a play maps hosts to tasks. Everything else is a keyword on one of those.
  • The order is fixed: facts → pre_tasks → handlers → roles → tasks → post_tasks → handlers. Roles run before tasks regardless of file order.
  • strategy: linear is the default and usually right. free is faster on mixed fleets and silently breaks anything that assumes hosts are in step.
  • Always write name:, and phrase it as desired state — “Ensure X is present”, not “Install X”.
  • Outcomes are ok / changed / failed / skipped, plus unreachable, which removes the host from the rest of the play.
  • Handlers: notification requires changed, they run at the end of the play, in definition order, once, and not at all if the play fails.
  • meta: flush_handlers runs them early; force_handlers: true runs them despite failure — worth defaulting to where a half-applied config is worse than an error.
  • listen lets one notification trigger several handlers, which is how a role publishes a stable name.
  • validate: on template and copy turns a broken config into a failed task instead of a dead service. visudo -cf %s is mandatory.

Next: modules and idempotence — what changed really means, why command is never idempotent, and the small number of keywords that let you tell Ansible the truth about what a task did.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source