AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution
Four ways to run something locally
What you'll learn
- Distinguish the four local-execution forms by host loop, variable scope and connection
- Choose the form that matches the stated intent rather than the shortest one
- Explain why controller-side execution is the right place for credential-bearing work
- Recognise the failure modes each form produces when applied to the wrong intent
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
“Run this on the controller” has four spellings in Ansible, and choosing between them by which one you saw most recently is how playbooks end up calling an API two hundred times, or once when they needed two hundred.
They are not synonyms. They differ on three axes that matter
operationally: how many times the task runs, whose variables it can
see, and which machine become escalates on.
The four forms, side by side
Here they are in one playbook, over a two-host inventory where each host
carries its own ansible_host and app_port:
$ ansible-playbook -i local4.ini local4.ymlTASK [1. delegate_to localhost] ************************************************
ok: [web1 -> localhost] => {
"msg": "ih=web1 ah=192.0.2.11 port=8080 conn=local"
}
ok: [web2 -> localhost] => {
"msg": "ih=web2 ah=192.0.2.12 port=8081 conn=local"
}
TASK [2. connection local] *****************************************************
ok: [web1] => {
"msg": "ih=web1 ah=192.0.2.11 port=8080 conn=local"
}
ok: [web2] => {
"msg": "ih=web2 ah=192.0.2.12 port=8081 conn=local"
}
TASK [3. local_action] *********************************************************
ok: [web1 -> localhost] => {
"msg": "ih=web1 ah=192.0.2.11 port=8080"
}
ok: [web2 -> localhost] => {
"msg": "ih=web2 ah=192.0.2.12 port=8081"
}
TASK [4. separate localhost play] **********************************************
ok: [localhost] => {
"msg": "ih=localhost app_port=NOT VISIBLE"
}Three of the four ran twice. One ran once. Three of the four could see
app_port. One could not. That is the whole lesson in one output block,
and the rest is why.
| Form | Runs | inventory_hostname | Target variables visible | Connection used |
|---|---|---|---|---|
delegate_to: localhost | once per host | the target | yes | localhost’s |
connection: local | once per host | the target | yes | forced to local |
local_action: | once per host | the target | yes | localhost’s |
separate hosts: localhost play | once total | localhost | no | localhost’s |
1. delegate_to: localhost
- name: Record this host in the deployment log
ansible.builtin.uri:
url: https://deploy.example.com/api/v1/events
method: POST
body_format: json
body:
host: "{{ inventory_hostname }}"
version: "{{ release_version }}"
headers:
Authorization: "Bearer {{ deploy_token }}"
delegate_to: localhost
no_log: true
The general delegation mechanism from lesson 2, aimed at the controller.
The host loop is intact, so this runs once per target and each run has
that target’s variables — which is what makes host: "{{ inventory_hostname }}" say something different every time.
Connection settings come from localhost, which means
ansible_python_interpreter is the controller’s. That is the form to
reach for by default, because it is the one that behaves like everything
else you know about delegation.
2. connection: local
- name: Render the per-host config on the controller for review
ansible.builtin.template:
src: app.conf.j2
dest: "build/{{ inventory_hostname }}.conf"
mode: '0640'
connection: local
Not delegation. This changes the connection plugin for one task while
leaving everything else about the host’s context alone. Note the output
above: task 2 shows ok: [web1] with no arrow, because from the play’s
point of view nothing was delegated anywhere.
The distinction is not cosmetic. delegate_to: localhost takes
localhost’s connection variables; connection: local keeps the target’s
and only overrides the transport. If your inventory sets an
ansible_python_interpreter per host group, that value still applies
under connection: local and does not under delegate_to: localhost —
which is the sort of difference that produces one broken group out of six.
Use it when the task is inherently controller-side but you want the host’s own context unaltered. Rendering per-host artefacts locally, as above, is the canonical case.
3. local_action:
- name: Legacy shorthand you will meet in older repositories
local_action:
module: ansible.builtin.wait_for
host: "{{ ansible_host }}"
port: 22
state: started
timeout: 300
Shorthand for delegating to the controller. Upstream describes it as
shorthand for delegating to 127.0.0.1; on ansible-core 2.21.3 the run
output reports it as -> localhost, matching form 1 in every observable
way.
There is one thing it is genuinely good for, and it is the example above:
waiting for a host to come back after a reboot. The task cannot run on the
host, because the host is down, and it needs ansible_host from the
host’s own context, which it gets.
Everything else it does, delegate_to: localhost does more legibly.
Prefer delegate_to in new code. Recognise local_action when you meet
it, and know it is a delegation so the rules from lesson 2 apply.
4. A separate hosts: localhost play
- name: Pre-flight, once for the whole run
hosts: localhost
gather_facts: false
connection: local
tasks:
- name: Fetch the release manifest
ansible.builtin.uri:
url: "https://artifacts.example.com/releases/{{ release_version }}.json"
return_content: true
register: manifest
- name: Refuse to proceed without a signed manifest
ansible.builtin.assert:
that:
- manifest.json.signature is defined
fail_msg: "Release {{ release_version }} has no signature; aborting."
The only form that runs once, and the only one where the app servers’
variables are not in scope — task 4 in the run above reported app_port
as NOT VISIBLE for exactly that reason.
That is the point of it. Work that is genuinely about the run as a whole — fetching a manifest, checking out a repository, validating an input, creating a change ticket — belongs here, where “once” is structural rather than something you asked for with a keyword and have to keep true.
Why controller-side execution matters for credentials
There is a security argument for controller-side execution that is independent of convenience, and it is the strongest reason to prefer form 1 or form 4 for a whole category of work.
The vault password lives on the controller. So does the SSH private
key, the API token in an environment variable, the cloud credential file,
and the netrc. A task delegated to localhost runs on the machine that
already, necessarily, holds all of it.
A task that runs on a managed node does not have any of that — and the ways people make it work are all bad:
- Copying an API token to every app server so a
uritask can use it. Now the token’s blast radius is your whole fleet, and rotating it is a fleet-wide change. - Enabling SSH agent forwarding so a managed node can reach a git server. Now anyone with root on that node can use your agent.
- Templating a credential into a file on the target to be read by the next task. Now it is on a disk, in a backup, and in whatever log captured the diff.
- name: Fetch this host database credential from the secret store
ansible.builtin.uri:
url: "https://vault.example.com/v1/secret/data/db/{{ inventory_hostname }}"
headers:
X-Vault-Token: "{{ lookup('env', 'VAULT_TOKEN') }}"
return_content: true
register: db_secret
delegate_to: localhost
no_log: true
- name: Write the credential to the host, and only the credential
ansible.builtin.template:
src: db.conf.j2
dest: /etc/app/db.conf
owner: appsvc
group: appsvc
mode: '0600'
no_log: trueThe token never leaves the controller. What reaches the managed node is
one file containing one credential scoped to that host, at mode 0600.
That is the shape to aim for whenever a secret is involved: fetch on the
controller, deliver the minimum.
Knowledge check
Knowledge check · 4 questions
Q1. A playbook needs to fetch a release manifest from an artefact server and abort if it is unsigned. The play targets 200 app servers. Which form is correct?
Q2. What is the observable difference between delegate_to: localhost and connection: local on the same task?
Q3. Why is a task that touches a secret usually better placed on the controller? Select all that apply.
Q4. A task with delegate_to: localhost and become: true escalates privileges on the controller, not on the managed node.
Passing score: 75%. Answers are checked in this browser.