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? #

  1. 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.
  2. High Complexity Level: Files that are too long make it hard for developers to trace where a workflow starts and ends.
  3. 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 --> K

The 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 DimensionStatic Import (import_tasks)Dynamic Include (include_tasks)
Load TimeStart of playbook (Compile Time)When the task executes (Runtime)
Speed PerformanceFaster during executionHas mid-flow disk read overhead
Variable UsageOnly supports static variablesSupports dynamic variables & facts
Tags ApplicationTags inherited by all sub-tasksTags only attach to the include line
Error PreventionSyntax errors detected upfrontSyntax 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: #

  1. Only Run When a Change Occurs: A handler only executes if the task sending notify has an output status of changed. If that task reports ok (because the server’s end state already matches), the handler won’t be triggered.
  2. Single Execution at the End: Even if 10 different tasks send notify to the same handler (for example: the config copy task, the SSL update task, and the virtual host edit task all send the Restart Nginx Service notify), Ansible only executes the Restart Nginx Service handler once at the end of the play after all main tasks finish.
  3. 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.yml file as the main controller using import_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; use include_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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact