Skip to main content
RunBook Academy

AnsibleXI · PlaybooksPlaybooks

The hosts: line is the blast radius

Intermediate⏱ ~15 minansible-playbookansible-inventory

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

Not yet marked complete on this device.

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:

PatternResolves to
webEvery host in group web
web01.example.comThat one host, if it is in the inventory
web:dbUnion — hosts in web or db
web:&stagingIntersection — hosts in web and staging
web:!web01.example.comExclusion — group web minus that host
web*Wildcard match against host and group names
allEvery host in the inventory
localhostThe 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:staginghosts (4): db02, web01, web02, web03
web:&staginghosts (1): web03
web:!web03.example.comhosts (2): web01, web02
web*hosts (3): the three web hosts
allhosts (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:

Read-only / Safewhat each play in this file will actually target
$ ansible-playbook -i inventory.ini site.yml --list-hosts
playbook: 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.com

Three 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:

Read-only / Safethe same file, limited to the web tier
$ ansible-playbook -i inventory.ini site.yml --list-hosts --limit web
playbook: 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:

Read-only / Safea pattern that matches nothing
$ 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

  1. Q1. Group web has 40 production hosts and group staging has 6. A play declares hosts: web:staging. How many hosts does it target?

  2. Q2. A --limit expression matches none of the hosts in a play. What happens?

  3. Q3. Why is one hosts: all play with per-task when: guards worse than several plays split by target? Select all that apply.

  4. 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.