AnsibleXI · PlaybooksPlaybooks
Composing a site.yml
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
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.
$ ansible-playbook -i inventory.ini site.yml --list-tasksplaybook: 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_playbookentries - 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.yml | Because |
|---|---|
| Tasks | The entry point becomes a place work hides. Nobody runs site.yml looking for a task list. |
A play with hosts: all | The one file everyone runs becomes the one file that touches everything |
| Variable definitions | Variables belong in inventory, where the values are visible next to the hosts they apply to |
Conditional imports based on a -e flag | The 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:
$ ansible-playbook -i inventory.ini site.yml --tags dbbPLAY [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:
- 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. - There is no dynamic equivalent.
include_taskshas a dynamic counterpart toimport_tasks; there is noinclude_playbook. Playbook composition is static, full stop. - A missing file fails at parse time, not at run time. That is good
news:
--syntax-checkcatches 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
Q1. What does import_playbook contribute to the importing file?
Q2. You run ansible-playbook site.yml --tags dbb, having meant --tags db. What happens?
Q3. Which of these belong in a site.yml entry point? Select all that apply.
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.