Delegation
Sometimes a task aimed at one host must actually run somewhere else. To drain web3 from the pool you run an HAProxy command on lb1.larkspur.io, not on web3 itself. delegate_to does exactly that: the play targets web, but a task with delegate_to: lb1.larkspur.io executes on the load balancer while still carrying the current web host's variables. This is the piece that makes a load-balancer-aware rolling deploy possible from a single play.
Delegation is one mechanism viewed from a few angles. The same machinery that runs a task on the load balancer also runs a task once instead of per host, runs it on the control node, and decides whose facts a delegated task updates. This topic takes each angle in turn, all anchored to the Larkspur drain-and-re-add pattern the chapter is building.
delegate_to — Run Here, Act There
A task with delegate_to: lb1.larkspur.io runs on lb1, but it keeps the inventory_hostname and variables of the host the play is currently iterating over. That combination is the whole point: the action lands on the load balancer, but it knows the name of the web host the play is working on, so you can tell HAProxy to drain "this specific web host" by name. The task's location moves to lb1; the task's context stays with web3.
The example below disables the current web host in HAProxy by delegating the command to lb1. The inventory_hostname in the command is still web3 — the host the play is on — even though the command itself runs on the load balancer.
- name: Drain {{ inventory_hostname }} from the HAProxy backend ansible.builtin.command: cmd: "echo 'disable server web_backend/{{ inventory_hostname }}' | socat stdio /run/haproxy/admin.sock" delegate_to: lb1.larkspur.io # runs on lb1, names the web host being deployed
The Drain and Re-add Pattern
This is the shape every load-balancer-aware deploy takes. Before you touch web3, you delegate a "disable server web3" command to lb1 so traffic stops flowing to it. After the deploy lands and a health check passes, you delegate an "enable server web3" command to lb1 to put it back in rotation. The web host is the subject the whole time — it is what is being deployed — while the load balancer is merely where the drain and re-add actions execute. One play, two delegated commands bracketing the real work.
run_once — Once Per Play, Not Per Host
run_once: true makes a task execute a single time, on the first targeted host, instead of once per host. It is for the genuinely fleet-singular step in a play that otherwise runs across many hosts — a single database migration that must happen once, a single "announce the deploy" notification, a one-time cache priming. Without run_once that step fires once for every host in the batch; with it, the step runs exactly once no matter how many hosts the play targets.
local_action and delegate_to localhost
Some tasks belong on the control node itself, not on any managed host — hitting an external API, posting a Slack message, templating a file locally. delegate_to: localhost runs the task on the control node without touching a managed node at all. The older local_action: syntax does the same thing and still works, but it obscures what is really happening: local_action is just delegation to the control node wearing a different name. Prefer the explicit delegate_to: localhost, which makes the mechanism plain.
delegate_facts — Whose Facts Get Set
By default, facts gathered or set during a delegated task attach to the original host the play is iterating over, not to the delegation target. That is usually what you want, but not always. delegate_facts: true flips it, attaching the gathered facts to the delegated host instead. This matters the moment you delegate a setup to read the load balancer's own facts: without delegate_facts, those facts land on web3 and you read them off the wrong host later; with it, they land on lb1 where they belong.
The trap is silent. The play does not error — it just attaches facts to the host you did not mean, and a later task reads a value that looks plausible and is wrong. Set delegate_facts deliberately whenever a delegated task gathers or sets facts you intend to read back from the target.
- Putting the HAProxy drain command directly in the
webplay withoutdelegate_to— it runs thehaproxycontrol on the web host, which is not the load balancer, and the drain silently does nothing or errors. - Expecting
delegate_toto also move the task's variables to the target host — it keeps the original host'sinventory_hostnameand vars, which is the whole point; reading the wrong host's facts here is a common surprise. - Using a delegated drain without thinking about
run_onceat larger batches — it is fine atserial: 1, but a single delegated command in a multi-host wave must name each host in the batch, not assume there is only one. - Forgetting
delegate_facts: truewhen delegating a fact-gathering task, then reading those facts off the original host later and acting on values that belong to the wrong machine. - Reaching for
local_actionwhendelegate_to: localhostis the current, clearer form — the older syntax still works but hides that it is simply delegation to the control node.
- Use
delegate_to: lb1.larkspur.iofor every load-balancer drain and re-add, so the play targetswebbut acts on the load balancer, keeping the rolling deploy in one readable play. - Pair each drain with a guaranteed re-add — put the re-add in a
block/alwaysor apost_task— so a failed deploy never strands a host drained out of the pool. - Use
run_oncefor genuinely fleet-singular steps — one migration, one notification — instead of letting them fire once per host in the batch. - Delegate external-system calls (Slack, monitoring, an API) to
localhostso they run on the control node, and setdelegate_factsdeliberately when you actually need the target's facts. - Name the host explicitly in delegated drain and re-add commands with
inventory_hostname, so the action onlb1is unambiguous even as the play iterates across the tier.
Knowledge Check
What does delegate_to keep, and what does it move?
- It moves where the task runs to the target, while keeping the original host's
inventory_hostnameand variables - It moves both the execution and the full variable context across to the delegation target host
- It keeps the task running on the original host and only changes which remote user account executes it under become
- It moves the variables over to the target host but still runs the task on the original host
Why must the HAProxy drain be delegated to lb1?
- The drain command has to run on the load balancer that holds the backend pool, not on the web host being deployed
- The web host is frozen and cannot run any command at all while it is being deployed to
- Delegation is the only way to escalate to root for the disable command on the load balancer
- HAProxy admin commands can only ever be issued from the control node itself, never from any managed host in the inventory
What does run_once: true change in a multi-host play?
- The task executes a single time on the first targeted host rather than once for every host
- The task runs on every host in the batch, but only during the play's very first execution ever
- The task runs exactly once per batch, with the batch size defined by the play's
serialsetting - The task is skipped on every host except the last one the play targets in the batch
When you delegate a fact-gathering task, whose facts does delegate_facts control?
- By default the facts attach to the original host;
delegate_facts: trueattaches them to the delegation target instead - Facts always attach to the delegation target by default, and
delegate_factsonly changes the moment within the run at which they are gathered and cached delegate_factsdiscards the gathered facts entirely, so neither the original nor the target host keeps them- The facts attach to the control node itself regardless of how the
delegate_factsflag is set
You got correct