Skip to main content
RunBook Academy

AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution

Four ways to run something locally

Advanced⏱ ~25 minansible-core

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

Not yet marked complete on this device.

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

Read-only / Safeall four, one run
$ ansible-playbook -i local4.ini local4.yml
TASK [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.

FormRunsinventory_hostnameTarget variables visibleConnection used
delegate_to: localhostonce per hostthe targetyeslocalhost’s
connection: localonce per hostthe targetyesforced to local
local_action:once per hostthe targetyeslocalhost’s
separate hosts: localhost playonce totallocalhostnolocalhost’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 uri task 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.
Read-only / Safesecret-bearing work on the controller
- 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: true

The 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

  1. 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?

  2. Q2. What is the observable difference between delegate_to: localhost and connection: local on the same task?

  3. Q3. Why is a task that touches a secret usually better placed on the controller? Select all that apply.

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