Structure #
Writing a playbook that merely works is very easy. As long as the YAML indentation structure is valid and the module parameters match, Ansible will execute the instructions. However, writing a well-structured playbook is a different infrastructure engineering art. A well-structured playbook doesn’t just successfully do its job today — it’s also easy for other development team members to read, doesn’t confuse during debugging, and doesn’t turn into a maintenance nightmare six months later. This article breaks down playbook file separation of responsibilities, master orchestrator management, internal element execution order, static vs dynamic importing comparisons, and advanced handlers handling.
Separation of Responsibilities Principle #
One of the most common design mistakes (anti-patterns) found in the field is composing a single monolithic playbook that handles the entire server lifecycle. That file contains hundreds of mixed tasks: installing Apache, configuring a MySQL database, pulling repository code, and installing monitoring agents.
# ANTI-PATTERN: A single monolithic file handling everything
# playbooks/site-monolithic.yml
---
- name: Setup and Configure the Entire Infrastructure
hosts: all
become: true
tasks:
- name: Update apt cache
apt: update_cache=yes
- name: Install Nginx
apt: name=nginx state=present
- name: Install PostgreSQL
apt: name=postgresql state=present
- name: Copy the application code
git: repo=https://github.com/org/app.git dest=/var/www
- name: Run the backup cronjob
cron: name="backup" hour="2" job="/opt/backup.sh"
Why Is This Monolithic Approach Bad? #
- Partial Testing Barrier: You can’t run one part of the tasks independently (for example, you only want to redeploy the application code) without re-evaluating the entire time-consuming database and monitoring system installation tasks.
- High Complexity Level: Files that are too long make it hard for developers to trace where a workflow starts and ends.
- Team Conflict Risk: Several developers working on different infrastructure parts (for example a database administrator and a frontend developer) will often hit git merge conflicts on the same file.
The site.yml Master Orchestrator #
The best design pattern is applying the One File, One Responsibility principle. You separate playbooks by role, then create a master playbook file named site.yml that acts as the main conductor (orchestrator) calling other sub-playbooks using the static import_playbook import module.
Here’s a tidy folder layout:
playbooks/
├── site.yml # Master playbook (Orchestrator)
├── 01-common-setup.yml # Base setup for all servers
├── 02-webservers.yml # Nginx web server-specific setup
├── 03-dbservers.yml # PostgreSQL database-specific setup
└── 04-app-deployment.yml # Application release deployment flow
And here’s the content of the master site.yml file:
# File: playbooks/site.yml
---
# Master Orchestrator Playbook
# 1. Call the base configuration (SSH hardening, firewall, ntp)
- import_playbook: 01-common-setup.yml
# 2. Call the database server configuration
- import_playbook: 03-dbservers.yml
# 3. Call the web proxy server configuration
- import_playbook: 02-webservers.yml
# 4. Call the application release flow (last because database & proxy must be ready first)
- import_playbook: 04-app-deployment.yml
Each sub-playbook file (like 02-webservers.yml) is a complete independent file containing its own play block with a specific hosts: target.
Internal Element Execution Order #
Inside a single Play block, you can declare several work elements besides the main tasks: block. Understanding the internal execution order is crucial so you don’t misplace prerequisite tasks.
Here’s the chronological order of elements running inside one Play:
1. Connection Check & Variable Loading
│
2. Target Fact Gathering (Gather Facts)
│
3. Execute the pre_tasks block
│
4. Evaluate & Execute Handlers (if triggered by pre_tasks)
│
5. Execute the roles block (Modular task packages)
│
6. Execute the tasks block (Main playbook tasks)
│
7. Execute the post_tasks block
│
8. Evaluate & Execute Handlers (final triggers from roles/tasks/post_tasks)
The play element execution flow above is fully visualized in the following flowchart:
flowchart TD
A["Start Play Execution"] --> B["1. Gather Data (Gather Facts)"]
B --> C["2. Run pre_tasks"]
C --> D{"Do pre_tasks trigger Notify?"}
D -- "Yes" --> E["Run Handlers (Pre-tasks scope)"]
D -- "No" --> F["3. Run Roles"]
E --> F
F --> G["4. Run Main tasks"]
G --> H["5. Run post_tasks"]
H --> I{"Do tasks/roles/post_tasks trigger Notify?"}
I -- "Yes" --> J["6. Run Handlers (End of Play)"]
I -- "No" --> K["Play Finished"]
J --> KThe Importance of pre_tasks and post_tasks: #
pre_tasks: Very useful for pre-connection health checks or taking servers out of the Load Balancer pool before the main installation role runs.post_tasks: Used to put servers back into the Load Balancer or trigger application health verification after the entire installation process finishes.
Static import vs Dynamic include Comparison #
Ansible provides two ways to split tasks into separate files so your code can be reused: Static Import (import_*) and Dynamic Include (include_*).
The fundamental difference between them lies in when Ansible reads the external file:
1. Static Import (Compile Time) #
Using the import_tasks module makes Ansible read and inject the entire external file contents into main memory when the playbook is first loaded (compilation time), before any task runs.
- Nature: Static and predictable.
- Limitation: You can’t use dynamic variables (variables produced by previous tasks) to determine the file name to import.
- Example:
tasks: # The install.yml file must exist before execution starts - import_tasks: tasks/install.yml
2. Dynamic Include (Execution Time) #
Using the include_tasks module makes Ansible read the external file only when that task line executes linearly (runtime).
- Nature: Dynamic and conditional.
- Advantage: You can use dynamic variables to determine which file to load. Very useful when designing multi-OS tasks.
- Example:
tasks: # Dynamically loads the installation file based on the target server OS type - include_tasks: "tasks/install-{{ ansible_os_family | lower }}.yml"
Tactical comparison table between Import and Include:
| Evaluation Dimension | Static Import (import_tasks) | Dynamic Include (include_tasks) |
|---|---|---|
| Load Time | Start of playbook (Compile Time) | When the task executes (Runtime) |
| Speed Performance | Faster during execution | Has mid-flow disk read overhead |
| Variable Usage | Only supports static variables | Supports dynamic variables & facts |
| Tags Application | Tags inherited by all sub-tasks | Tags only attach to the include line |
| Error Prevention | Syntax errors detected upfront | Syntax errors only detected when the line is read |
Task Naming Standardization #
The task name (- name:) is the most important part of a playbook’s readability. A good task name should explain the business goal (state) to achieve, not the technical command mechanism being run.
# ANTI-PATTERN: Writing CLI command mechanisms
- name: apt install nginx
apt:
name: nginx
state: present
- name: cp nginx.conf /etc/nginx/
copy:
src: files/nginx.conf
dest: /etc/nginx/nginx.conf
# CORRECT: Explaining the functional goal of the task
- name: Ensure the Nginx web server is installed
apt:
name: nginx
state: present
- name: Copy the main Nginx configuration from the local repository
copy:
src: files/nginx.conf
dest: /etc/nginx/nginx.conf
Writing descriptive task names helps you trace logs visually in the terminal, makes error tracking easier during CI/CD integration, and makes finding starting points easier using the --start-at-task flag.
Using Tags #
Tags are a very powerful feature for granularly controlling playbook execution without changing physical code. By tagging your tasks with specific tags, you can choose to only execute a task subset, or skip certain tasks.
Example of writing tags in a playbook file:
# File: playbooks/webservers.yml
---
- name: Configure the Web Server
hosts: webservers
become: true
tasks:
- name: Install the Nginx system package
apt:
name: nginx
state: present
tags:
- install
- nginx
- name: Copy the SSL certificate file
copy:
src: files/domain.crt
dest: /etc/ssl/certs/domain.crt
tags:
- configure
- ssl
- nginx
- name: Pull the latest application code from Git
git:
repo: https://github.com/org/app.git
dest: /var/www/html
tags:
- deploy
- app
How to use tags from the CLI command line:
# Scenario 1: Only run tasks with the 'deploy' tag
ansible-playbook -i inventory/hosts.ini site.yml --tags "deploy"
# Scenario 2: Run all tasks EXCEPT those related to 'ssl'
ansible-playbook -i inventory/hosts.ini site.yml --skip-tags "ssl"
# Scenario 3: Only run tasks for SSL configuration on web servers
ansible-playbook -i inventory/hosts.ini site.yml --tags "configure,ssl"
How Handlers Work #
Handlers are special tasks acting as event listeners. They’re designed to handle post-change operations like triggering service reloads, database restarts, or flushing memory caches.
Important Handler Characteristics: #
- Only Run When a Change Occurs: A handler only executes if the task sending
notifyhas an output status ofchanged. If that task reportsok(because the server’s end state already matches), the handler won’t be triggered. - Single Execution at the End: Even if 10 different tasks send
notifyto the same handler (for example: the config copy task, the SSL update task, and the virtual host edit task all send theRestart Nginx Servicenotify), Ansible only executes theRestart Nginx Servicehandler once at the end of the play after all main tasks finish. - Ignored If the Playbook Fails: If your playbook execution fails (failed task) mid-way before the play finishes, all already-triggered handlers will never run to protect system safety.
# Multiple handler trigger illustration
tasks:
- name: Update the web virtual host configuration
template:
src: vhost.conf.j2
dest: /etc/nginx/sites-available/app
notify: Restart Nginx Service
- name: Copy the production SSL certificate file
copy:
src: files/prod.crt
dest: /etc/ssl/certs/prod.crt
notify: Restart Nginx Service
handlers:
- name: Restart Nginx Service
systemd:
name: nginx
state: restarted
Instant Execution via flush_handlers #
In certain scenarios, you don’t want to wait until all main tasks finish to run a handler. For example, you change the database port on the first task and trigger a database service restart. Subsequent tasks need to immediately connect to that database to run schema migrations. If the database restart is deferred until the end of the play, the mid-flow migration task will fail (failed connection).
Ansible provides a meta module called flush_handlers to force the instant execution of all currently queued handlers right away:
# Scenario forcing instant handler execution
tasks:
- name: Change the default PostgreSQL database port
template:
src: postgresql.conf.j2
dest: /etc/postgresql/15/main/postgresql.conf
notify: Restart PostgreSQL Service
# Force queued handler execution at this line
- name: Run the queued handlers immediately
meta: flush_handlers
# This task is safe to run because the PostgreSQL service is already up on the new port
- name: Run the application database schema migration
command: python manage.py migrate
changed_when: true
handlers:
- name: Restart PostgreSQL Service
systemd:
name: postgresql
state: restarted
Recursive Handlers (Triggering Other Handlers) #
A handler can also send notify to other handlers. For example, when you restart the Nginx web server service, you also want to trigger a connection health check tester module. This is allowed by Ansible and is processed chained at the end of the play.
Summary #
- site.yml Orchestrator — Separate playbooks by responsibility area, then use one master
site.ymlfile as the main controller usingimport_playbook.- Play Element Order — Understand and follow the chronological element order: pre_tasks -> roles -> tasks -> post_tasks -> handlers to minimize prerequisite errors.
- Import vs Include Selection Criteria — Use
import_tasks(Compile Time) for common task stability; useinclude_tasks(Runtime) if file names are affected by dynamic variables.- Descriptive Name Declarations — Write task name parameters focusing on the business goal (state) to achieve, not just shell commands.
- Execution Tuning via Tags — Apply tactical tagging to every task to enable quick maintenance like skipping system installs when you only want to deploy code.
- Handler Work Efficiency — Leverage handlers to trigger service restart/reload; the single-execution-at-end-of-play feature saves target server status negotiation time.
- Handler Protection — Remember that a main task failure mid-way cancels handler execution to protect servers from half-inconsistent states.
← Previous: What is a Playbook? Next: Task & Module Execution →