Skip to main content
RunBook Academy

AnsibleXI · PlaybooksPlaybooks

Composing a site.yml

Intermediate⏱ ~15 minansible-playbook

What you'll learn

  • Assemble several playbook files into one entry point with import_playbook
  • Decide what belongs in site.yml and what does not
  • Run a subset of a composed estate without editing the entry point
  • Explain why import_playbook is static and what that constrains

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

An estate is not one playbook. It is a load balancer tier, a web tier, a database tier, a monitoring tier and a handful of one-off procedures, each of which someone needs to run on its own at three in the morning.

It is also, occasionally, one command: “bring the whole environment to its declared state”. Those two requirements pull in opposite directions, and import_playbook is how you satisfy both without duplicating a line.

One entry point, several files

import_playbook includes another playbook file at the top level of the current one. It is a play-level construct, not a task — it appears where a play would appear, and it contributes that file’s plays to this file’s play list.

# site.yml
- name: Load balancer tier
  import_playbook: loadbalancer.yml

- name: Web tier
  import_playbook: webservers.yml

- name: Database tier
  import_playbook: database.yml

Each of those files is a normal, independently runnable playbook:

# loadbalancer.yml
- name: Configure the load balancer
  hosts: lb
  gather_facts: false
  tags: ['lb']
  tasks:
    - name: Render the backend pool
      ansible.builtin.debug:
        msg: 'pool'
      tags: ['config']

ansible-playbook loadbalancer.yml works. ansible-playbook site.yml works and runs it as part of the sequence. Nothing is duplicated, and the file you edit when the balancer configuration changes is the one called loadbalancer.yml.

The composed result behaves exactly as if you had concatenated the files: plays run in order, one completely before the next.

Read-only / Safethe composed play list
$ ansible-playbook -i inventory.ini site.yml --list-tasks
playbook: site.yml

play #1 (lb): Configure the load balancer	TAGS: [lb]
  tasks:
    Render the backend pool	TAGS: [config, lb]

play #2 (web): Deploy the web tier	TAGS: []
  tasks:
    Take the host out of the load balancer pool	TAGS: []
    webserver : R1 role task, notifies H-role	TAGS: []
    Apply the site-specific tuning	TAGS: []
    Return the host to the load balancer pool	TAGS: []

play #3 (db): Configure the database tier	TAGS: [db]
  tasks:
    Check replication lag	TAGS: [db, health]

The plays are numbered #1, #2, #3 — the import boundaries have disappeared entirely by the time the playbook is compiled. That is the point.

What site.yml should contain

Very little. A good site.yml is a table of contents:

  • A list of import_playbook entries
  • In deliberate order, because that order is a dependency statement
  • With a comment where the order is load-bearing and not obvious

What it should not contain:

Do not put this in site.ymlBecause
TasksThe entry point becomes a place work hides. Nobody runs site.yml looking for a task list.
A play with hosts: allThe one file everyone runs becomes the one file that touches everything
Variable definitionsVariables belong in inventory, where the values are visible next to the hosts they apply to
Conditional imports based on a -e flagThe entry point now behaves differently depending on how it was invoked, and the difference is invisible in Git

The test is whether someone can answer “what does running this do?” by reading site.yml alone. A table of contents passes; a table of contents with three tasks bolted on does not.

Running a subset without editing the entry point

The reason to compose rather than concatenate is that you keep the ability to run a slice. Three mechanisms, in order of how much they constrain:

Run the component playbook directly. The cleanest option, and it works because each imported file is a valid playbook on its own:

ansible-playbook -i inventory.ini database.yml

Limit the hosts. Same entry point, narrower target. Plays that resolve to zero hosts are skipped:

ansible-playbook -i inventory.ini site.yml --limit db

Select by tag. A play-level tag makes the whole play selectable, and it is inherited by every task in that play:

ansible-playbook -i inventory.ini site.yml --tags db

Use --list-tags first, every time, because a tag that does not exist is not an error:

Read-only / Safea mistyped tag
$ ansible-playbook -i inventory.ini site.yml --tags dbb
PLAY [Configure the load balancer] *********************************************

PLAY [Deploy the web tier] *****************************************************

PLAY [Configure the database tier] *********************************************

PLAY RECAP *********************************************************************

Three play headers, no tasks, no recap lines, exit 0. Indistinguishable at a glance from a successful no-op run against an already-converged estate, which is exactly why this one reaches production.

import_playbook is static, and that has consequences

The import happens when the playbook is parsed, before anything runs. Three consequences follow, and all three surprise people:

  1. The path cannot depend on a runtime value. You cannot write import_playbook: "{{ tier }}.yml" using a variable that a task sets, because the import is resolved before any task exists. Variables available at parse time — extra vars, for instance — do work, but that is a design you should be reluctant to adopt for the reason in the table above.
  2. There is no dynamic equivalent. include_tasks has a dynamic counterpart to import_tasks; there is no include_playbook. Playbook composition is static, full stop.
  3. A missing file fails at parse time, not at run time. That is good news: --syntax-check catches a broken import before you connect to anything.

import_playbook also cannot be conditional in the way a task can. It accepts when: without complaint, but because the import is static, the condition is pushed down onto the tasks of the imported plays rather than applied to the import itself.

Measured on 2.21.3: an import_playbook guarded by when: run_db | default(false) | bool, run without setting run_db, still appeared in --list-tasks and still produced a play that connected to its hosts. The recap was ok=0 changed=0 unreachable=0 failed=0 skipped=1. The play ran; its tasks were skipped.

If your intent was “do not touch these hosts at all”, that construct does not express it. Split the entry point instead, or select with --limit or --tags.

Knowledge check

Knowledge check · 4 questions

  1. Q1. What does import_playbook contribute to the importing file?

  2. Q2. You run ansible-playbook site.yml --tags dbb, having meant --tags db. What happens?

  3. Q3. Which of these belong in a site.yml entry point? Select all that apply.

  4. Q4. A playbook file imported by site.yml remains an ordinary playbook and can still be run on its own.

Passing score: 75%. Answers are checked in this browser.