| |

LFCE 149 ๐Ÿง Ansible โ€” Writing Playbooks

โฑ๏ธ Estimated read: 11 min | |

A playbook is the file that turns a collection of ad-hoc tasks into a repeatable, version-controlled procedure. It is a YAML document that maps a group of hosts to an ordered list of tasks, and each task is a single module invocation with a name and parameters. The playbook is the unit that Ansible runs: ansible-playbook site.yml reads the inventory, resolves the host list, executes the tasks in order, and reports the result for each host. The structure is deliberately readable, and the readability is the point: a playbook is documentation that executes.

This chapter covers the playbook structure, the play and task keys that appear in almost every playbook, the handlers that run only when a task reports a change, the variables that parameterize the tasks, and the idempotency property that makes a playbook safe to re-run. It also covers the pre-flight commands that catch syntax and logic errors before they reach a production host.

Key point: A playbook is a list of plays. A play maps hosts to tasks. A task invokes a module. A handler is a task that runs only when notified by a changed task. The playbook is idempotent when every task uses a module that checks the current state before acting.


Why playbooks are structured this way

The readability problem. Ansible modules are the tools; playbooks are the instruction manual . A playbook that is a wall of shell commands is a script, not a playbook. The structureโ€”named plays, named tasks, modules with declarative parametersโ€”makes the playbook reviewable by someone who did not write it. The name field on every play and task appears in the PLAY RECAP output and makes a failure self-explanatory .

The ordering problem. A play executes its tasks in the order they are listed. The handlers run at the end of the play, not immediately after the task that notifies them. This deferral is deliberate: if three tasks update a configuration file and notify the same restart handler, the handler runs once, not three times . The meta: flush_handlers task forces the handlers to run at a specific point in the play when the order matters .

The idempotency problem. A playbook should produce the same end state whether it runs once or ten times. The modules support this: state: present installs a package only if it is absent, and state: started starts a service only if it is stopped. A task that uses shell or command does not have this property unless it is explicitly guarded with changed_when or creates . The idempotency is what makes a playbook safe to run on a schedule or in a CI pipeline .


a. The play and its keys

A play is the top-level entry in the playbook list. It has three required keys and several optional ones.

---
- name: Configure web servers
  hosts: webservers
  become: true
  vars:
    app_version: "1.0.0"
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present

The name is the human-readable description of the play. The hosts is the inventory group or host pattern that the play targets. The become enables privilege escalation for the tasks in the play . The vars defines play-level variables. The tasks is the ordered list of tasks.

The gather_facts key controls whether Ansible collects system facts at the start of the play. The default is true, and the facts are available as ansible_facts. A play that does not need the facts can set gather_facts: false to skip the collection and save time .


b. The task and its keys

A task is a single module invocation. The name is the description, and the module key is the name of the module. The module’s parameters are nested under the module key.

    - name: Deploy nginx configuration
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        owner: root
        mode: "0644"
      notify: Restart nginx

The template module renders a Jinja2 template from the control node and writes the result to the target. The notify key names a handler that runs if the task reports a change . The owner and mode set the file’s permissions.

The loop key repeats a task once per item in a list. The when key gates a task on a condition. The register key captures the task’s return data into a variable for later use .

    - name: Install packages
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
      loop:
        - nginx
        - git
        - curl

c. Handlers

A handler is a task that runs only when it is notified. It is defined in a handlers block, and it is referenced by name from a task’s notify key.

    - name: Deploy configuration
      ansible.builtin.template:
        src: app.conf.j2
        dest: /etc/app/app.conf
      notify: Restart app

  handlers:
    - name: Restart app
      ansible.builtin.service:
        name: app
        state: restarted

The handler runs at the end of the play, after all the tasks have completed . If multiple tasks notify the same handler, it runs only once. The handler runs only if at least one notifying task reported changed; if every task reported ok, the handler does not run . This is why the handler is the correct place for a service restart: the restart happens only when the configuration actually changed.


d. Variables and facts

Variables parameterize the playbook. They can be defined at the play level (vars), in the inventory (group_vars, host_vars), or passed on the command line (-e). The variable precedence order determines which value wins when the same name is defined in more than one place .

Facts are auto-discovered host data. They are gathered at the start of the play and are available as ansible_facts. A conditional can gate a task on a fact:

    - name: Harden SSH
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PermitRootLogin'
        line: 'PermitRootLogin no'
      when: ansible_facts['os_family'] == "RedHat"
      notify: Restart sshd

The when clause checks the operating system family and applies the hardening only on RedHat-family hosts . The fact is available because the play gathered facts at the start.


Complete Example Session

# ============================================
# PART 1: THE MINIMAL PLAYBOOK
# ============================================
---
- name: Configure web servers
  hosts: webservers
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
# ============================================
# PART 2: THE PLAY KEYS
# ============================================
- name: Configure web servers
  hosts: webservers
  become: true
  gather_facts: true
  vars:
    app_version: "1.0.0"
# ============================================
# PART 3: THE TASK KEYS
# ============================================
    - name: Deploy configuration
      ansible.builtin.template:
        src: app.conf.j2
        dest: /etc/app/app.conf
        owner: root
        mode: "0644"
      notify: Restart app
# ============================================
# PART 4: THE LOOP
# ============================================
    - name: Install packages
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
      loop:
        - nginx
        - git
        - curl
# ============================================
# PART 5: THE CONDITIONAL
# ============================================
    - name: Harden SSH
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PermitRootLogin'
        line: 'PermitRootLogin no'
      when: ansible_facts['os_family'] == "RedHat"
      notify: Restart sshd
# ============================================
# PART 6: THE REGISTER
# ============================================
    - name: Check disk usage
      ansible.builtin.command: df -h /
      register: disk_usage
      changed_when: false

    - name: Show disk usage
      ansible.builtin.debug:
        msg: "{{ disk_usage.stdout }}"
# ============================================
# PART 7: THE HANDLERS
# ============================================
  handlers:
    - name: Restart app
      ansible.builtin.service:
        name: app
        state: restarted

    - name: Restart sshd
      ansible.builtin.service:
        name: sshd
        state: restarted
# ============================================
# PART 8: THE FLUSH
# ============================================
    - name: Flush handlers
      ansible.builtin.meta: flush_handlers

# Handlers run at this point, not at the end of the play.
# ============================================
# PART 9: THE PRE-FLIGHT CHECKS
# ============================================
ansible-playbook --syntax-check site.yml
ansible-playbook --check --diff -i inventory.ini site.yml
# ============================================
# PART 10: THE RUN
# ============================================
ansible-playbook -i inventory.ini site.yml

The ten parts covered the minimal playbook, the play keys, the task keys, the loop, the conditional, the register, the handlers, the flush, the pre-flight checks, and the run.


Quick Reference

Play keys

KeyPurpose
nameHuman-readable description
hostsInventory group or pattern
becomePrivilege escalation
gather_factsCollect system facts
varsPlay-level variables
tasksOrdered task list
handlersNotified task list

Task keys

KeyPurpose
nameTask description
notifyHandler to trigger on change
loopRepeat per item
whenConditional gate
registerCapture return data
changed_whenOverride change detection
failed_whenOverride failure detection

Handlers

RuleDetail
TriggerTask reports changed and calls notify
TimingEnd of play by default
DeduplicationMultiple notifies โ†’ one run
Flushmeta: flush_handlers
Skip on okHandler does not run if no change

Pre-flight commands

CommandPurpose
ansible-playbook --syntax-checkValidate YAML and module names
ansible-playbook --check --diffDry run with diff
ansible-playbook --list-tasksList tasks without running

Best Practices

โœ… Do This:

# Name every play and task
- name: Install nginx
  ansible.builtin.apt: ...                                  # โœ…
# Use modules instead of shell/command
ansible.builtin.apt:
  name: nginx
  state: present                                             # โœ…
# Use handlers for service restarts
notify: Restart nginx                                        # โœ…
# Set changed_when: false on read-only commands
changed_when: false                                          # โœ…

โŒ Don’t Do This:

# Use shell for a task that has a module
shell: apt install nginx                                     # โŒ
# Put variables in handler names
- name: Restart "{{ service_name }}"                         # โŒ
# Forget the quotes around Jinja2 in a value
msg: {{ variable }}                                          # โŒ

Common Pitfalls

PitfallWhy It HappensFix
Task always reports changedshell or command usedUse a module or set changed_when
Handler never runsNo task notified it or no changeCheck notify and the task’s changed status
Handler runs at the wrong timeHandlers run at the end of the playUse meta: flush_handlers
Variable is undefinedNot defined in vars, inventory, or -eDefine it or use a default
YAML syntax errorTabs or inconsistent indentationUse 2 spaces; run --syntax-check

Real-World Examples

1. Install and start a service

- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present
- name: Start nginx
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

2. Template with a handler

- name: Deploy config
  ansible.builtin.template:
    src: app.conf.j2
    dest: /etc/app/app.conf
  notify: Restart app

3. Loop over packages

- name: Install packages
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
  loop: [nginx, git, curl]

4. Conditional by OS family

when: ansible_facts['os_family'] == "RedHat"

5. Register a command

- command: df -h /
  register: disk
  changed_when: false

6. Handler definition

handlers:
  - name: Restart app
    ansible.builtin.service:
      name: app
      state: restarted

7. Flush handlers

- meta: flush_handlers

8. Syntax check

ansible-playbook --syntax-check site.yml

9. Dry run

ansible-playbook --check --diff site.yml

10. Run

ansible-playbook -i inventory.ini site.yml

Visual

The playbook structure

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  PLAYBOOK (YAML list)                                        โ”‚
โ”‚    โ””โ”€โ”€ PLAY                                                  โ”‚
โ”‚          โ”œโ”€โ”€ name                                            โ”‚
โ”‚          โ”œโ”€โ”€ hosts                                           โ”‚
โ”‚          โ”œโ”€โ”€ become                                          โ”‚
โ”‚          โ”œโ”€โ”€ tasks                                           โ”‚
โ”‚          โ”‚     โ”œโ”€โ”€ TASK 1 (module + params)                  โ”‚
โ”‚          โ”‚     โ”œโ”€โ”€ TASK 2 (module + params + notify)         โ”‚
โ”‚          โ”‚     โ””โ”€โ”€ TASK 3 (module + params + loop)           โ”‚
โ”‚          โ””โ”€โ”€ handlers                                        โ”‚
โ”‚                โ””โ”€โ”€ HANDLER (triggered by notify)             โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The task lifecycle

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Task runs                                                   โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ”œโ”€โ”€ changed โ”€โ”€โ–บ notify handler (queued)               โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ”œโ”€โ”€ ok โ”€โ”€โ–บ no handler                                 โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ””โ”€โ”€ failed โ”€โ”€โ–บ play stops (unless ignore_errors)      โ”‚
โ”‚                                                              โ”‚
โ”‚  Handlers run at end of play (or at flush_handlers).         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Handler deduplication

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Task 1 notifies Restart nginx                               โ”‚
โ”‚  Task 2 notifies Restart nginx                               โ”‚
โ”‚  Task 3 notifies Restart nginx                               โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ–ผ                                                      โ”‚
โ”‚  Restart nginx runs ONCE at end of play                      โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Idempotency

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  First run:                                                  โ”‚
โ”‚    apt: state=present โ”€โ”€โ–บ install โ”€โ”€โ–บ changed                โ”‚
โ”‚                                                              โ”‚
โ”‚  Second run:                                                 โ”‚
โ”‚    apt: state=present โ”€โ”€โ–บ already installed โ”€โ”€โ–บ ok           โ”‚
โ”‚                                                              โ”‚
โ”‚  The module checks the state before acting.                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
File formatYAML
Document start---
Top-level structureList of plays
Play keysname, hosts, become, tasks, handlers
Task keysname, module, notify, loop, when, register
Handler triggerchanged + notify
Handler timingEnd of play, or meta: flush_handlers
IdempotencyModules check state before acting
Pre-flight--syntax-check, --check --diff

Key takeaways:

  • A playbook is a list of plays, and a play maps hosts to tasks. The hosts key selects the inventory group. The tasks key lists the module invocations in order. The handlers key defines the tasks that run only when notified .
  • Handlers run at the end of the play, not immediately. A task that reports changed and calls notify queues the handler. Multiple notifications to the same handler are deduplicated into one run. The meta: flush_handlers task forces the queue to run at a specific point .
  • Idempotency is the property that makes a playbook safe to re-run. A module like apt with state: present checks whether the package is installed before acting. A shell or command task does not have this property unless it is guarded with changed_when or creates .
  • Variables and facts parameterize the playbook. Play-level vars, inventory group_vars and host_vars, and command-line -e provide the values. Facts from ansible_facts drive conditionals like the OS-family check .
  • The pre-flight commands catch errors before they reach production. --syntax-check validates the YAML and the module names. --check --diff runs a dry run and shows what would change .
  • The name field on every play and task is not optional in practice. It appears in the PLAY RECAP output and makes a failure self-explanatory. A playbook without names is a playbook that is hard to debug .

Remember: A playbook is the file that turns a set of tasks into a repeatable procedure. The structure is plays, tasks, and handlers. The play maps hosts to tasks. The task invokes a module. The handler runs only when a task reports a change. The idempotency property comes from the modules: they check the current state before acting. Use --syntax-check and --check --diff before the real run. And name every play and task, because the name is what appears in the output when something fails.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!