AnsibleXI · PlaybooksPlaybooks
The hosts: line is the blast radius
What you'll learn
- Treat the play target expression as the primary review artefact of a playbook
- Resolve a target expression to a concrete host list before running anything
- Split work into plays by target rather than filtering inside a single play
- Recognise the target-expression mistakes that produce fleet-wide changes
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
Everything else in a playbook decides what happens. One line decides to how many machines.
hosts: web
Change that word to all and the same file, unchanged in every other
respect, becomes a fleet-wide operation. No syntax error, no warning, no
difference in how the playbook reads at a glance. This is why the first
thing to read in any playbook review — before the tasks, before the
variables — is every hosts: line in the file.
The expression is a pattern, not a group name
hosts: accepts a pattern, and patterns compose. The forms you will
meet, with what each resolves to:
| Pattern | Resolves to |
|---|---|
web | Every host in group web |
web01.example.com | That one host, if it is in the inventory |
web:db | Union — hosts in web or db |
web:&staging | Intersection — hosts in web and staging |
web:!web01.example.com | Exclusion — group web minus that host |
web* | Wildcard match against host and group names |
all | Every host in the inventory |
localhost | The controller, even if it is not in the inventory |
Composition is where the mistakes live. web:staging is a union of two
groups and is almost never what someone typing it meant; they wanted the
intersection web:&staging, “the staging web servers”. The union of
the two is “all web servers plus everything in staging”, which on a real
inventory includes production web hosts.
Both forms resolved against the same three-group inventory, on
ansible-core 2.21.3, where web holds three hosts and staging holds
web03.example.com and db02.example.com:
| Pattern | --list-hosts result |
|---|---|
web:staging | hosts (4): db02, web01, web02, web03 |
web:&staging | hosts (1): web03 |
web:!web03.example.com | hosts (2): web01, web02 |
web* | hosts (3): the three web hosts |
all | hosts (5): everything |
The union pulled in a database server. That is what “reads like a filter” costs.
Resolve it before you run it
The expression is not the host list. The host list is what the expression plus the current inventory produce, and inventory changes under you — a dynamic inventory can gain hosts between two runs without anyone editing a file.
--list-hosts answers the question directly, connecting to nothing:
$ ansible-playbook -i inventory.ini site.yml --list-hostsplaybook: site.yml
play #1 (lb): Configure the load balancer TAGS: [lb]
pattern: ['lb']
hosts (1):
lb01.example.com
play #2 (web): Deploy the web tier TAGS: []
pattern: ['web']
hosts (2):
web02.example.com
web01.example.com
play #3 (db): Configure the database tier TAGS: [db]
pattern: ['db']
hosts (1):
db01.example.comThree numbers to read: hosts (1), hosts (2), hosts (1). If any of
them is larger than you expected, stop. The count is the blast radius of
that play, and it is the only number in the pre-flight ladder that tells
you how much of the estate is in scope.
--limit narrows; it never widens
--limit intersects the play’s pattern with the limit expression. It
can only reduce the host list:
$ ansible-playbook -i inventory.ini site.yml --list-hosts --limit webplaybook: site.yml
play #1 (lb): Configure the load balancer TAGS: [lb]
pattern: ['lb']
hosts (0):
play #2 (web): Deploy the web tier TAGS: []
pattern: ['web']
hosts (2):
web01.example.com
web02.example.com
play #3 (db): Configure the database tier TAGS: [db]
pattern: ['db']
hosts (0):hosts (0) for the load-balancer and database plays is not an error. A
play whose pattern intersects the limit to nothing is skipped and the
run continues:
$ ansible-playbook -i inv3.ini pat.yml[WARNING]: Could not match supplied host pattern, ignoring: nosuchgroup
PLAY [Pattern test] ************************************************************
skipping: no hosts matched
PLAY RECAP *********************************************************************
A warning on stderr, an empty recap, and exit code 0. A --limit
typo therefore does not fail loudly — it produces a run that does less
than you asked, quietly, and reports success. Check the count for
every play, not just the one you were thinking about.
Split by target, do not filter inside a play
When part of the work belongs to a different set of machines, that is a
new play. The alternative — one play targeting everything, with when:
guards on each task — is worse in every way that matters:
# Do not do this
- name: Configure everything
hosts: all
tasks:
- name: Configure nginx
ansible.builtin.debug: { msg: nginx }
when: "'web' in group_names"
- name: Configure postgres
ansible.builtin.debug: { msg: postgres }
when: "'db' in group_names"
The hosts: all is now a lie that only the when: clauses make safe.
Every review of this file has to check every conditional to work out
what it touches. Delete one when: by accident and a database server
gets an nginx configuration. --list-hosts reports the entire fleet,
because it is telling you the truth: the play targets the entire fleet
and then declines to do most of the work.
The same work, split by target:
- name: Configure the web tier
hosts: web
tasks:
- name: Configure nginx
ansible.builtin.debug: { msg: nginx }
- name: Configure the database tier
hosts: db
tasks:
- name: Configure postgres
ansible.builtin.debug: { msg: postgres }
Now --list-hosts reports the real scope of each piece of work, a
deleted line can only break one tier, and the file states its intent in
two words per play instead of one conditional per task.
There is a legitimate use of when: on host attributes — branching on
an OS family or a fact that varies within a correctly targeted group.
The test is whether the condition is deciding which machines (belongs
in hosts:) or how to do the work on machines that are correctly in
scope (belongs in when:).
gather_facts: false and the pattern
A play that targets a group only to run a controller-side task does not need facts, and gathering them means a connection to every host in the pattern. On a wide pattern that turns a “harmless” play into several hundred SSH sessions.
More importantly, fact gathering is often the only thing in such a play that touches the hosts at all — which means turning it off changes the play from “connects to 400 machines” to “connects to none”. That is a blast-radius decision, not a performance tweak.
Knowledge check
Knowledge check · 4 questions
Q1. Group web has 40 production hosts and group staging has 6. A play declares hosts: web:staging. How many hosts does it target?
Q2. A --limit expression matches none of the hosts in a play. What happens?
Q3. Why is one hosts: all play with per-task when: guards worse than several plays split by target? Select all that apply.
Q4. The set of hosts --list-hosts reports is stable, but the order in which it prints them is not, so it cannot tell you which host a play will touch first.
Passing score: 75%. Answers are checked in this browser.