LFCE 149 ๐ง Ansible โ Writing Playbooks
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
| Key | Purpose |
|---|---|
name | Human-readable description |
hosts | Inventory group or pattern |
become | Privilege escalation |
gather_facts | Collect system facts |
vars | Play-level variables |
tasks | Ordered task list |
handlers | Notified task list |
Task keys
| Key | Purpose |
|---|---|
name | Task description |
notify | Handler to trigger on change |
loop | Repeat per item |
when | Conditional gate |
register | Capture return data |
changed_when | Override change detection |
failed_when | Override failure detection |
Handlers
| Rule | Detail |
|---|---|
| Trigger | Task reports changed and calls notify |
| Timing | End of play by default |
| Deduplication | Multiple notifies โ one run |
| Flush | meta: flush_handlers |
Skip on ok | Handler does not run if no change |
Pre-flight commands
| Command | Purpose |
|---|---|
ansible-playbook --syntax-check | Validate YAML and module names |
ansible-playbook --check --diff | Dry run with diff |
ansible-playbook --list-tasks | List 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Task always reports changed | shell or command used | Use a module or set changed_when |
| Handler never runs | No task notified it or no change | Check notify and the task’s changed status |
| Handler runs at the wrong time | Handlers run at the end of the play | Use meta: flush_handlers |
| Variable is undefined | Not defined in vars, inventory, or -e | Define it or use a default |
| YAML syntax error | Tabs or inconsistent indentation | Use 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
| Item | Value |
|---|---|
| File format | YAML |
| Document start | --- |
| Top-level structure | List of plays |
| Play keys | name, hosts, become, tasks, handlers |
| Task keys | name, module, notify, loop, when, register |
| Handler trigger | changed + notify |
| Handler timing | End of play, or meta: flush_handlers |
| Idempotency | Modules 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
hostskey selects the inventory group. Thetaskskey lists the module invocations in order. Thehandlerskey defines the tasks that run only when notified . - Handlers run at the end of the play, not immediately. A task that reports
changedand callsnotifyqueues the handler. Multiple notifications to the same handler are deduplicated into one run. Themeta: flush_handlerstask forces the queue to run at a specific point . - Idempotency is the property that makes a playbook safe to re-run. A module like
aptwithstate: presentchecks whether the package is installed before acting. Ashellorcommandtask does not have this property unless it is guarded withchanged_whenorcreates. - Variables and facts parameterize the playbook. Play-level
vars, inventorygroup_varsandhost_vars, and command-line-eprovide the values. Facts fromansible_factsdrive conditionals like the OS-family check . - The pre-flight commands catch errors before they reach production.
--syntax-checkvalidates the YAML and the module names.--check --diffruns a dry run and shows what would change . - The
namefield on every play and task is not optional in practice. It appears in thePLAY RECAPoutput 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.
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!