Delegation & Local Action #
When writing Ansible playbooks, we often assume every task in a play must be executed directly on the managed node currently being processed. This is Ansible’s default behavior, which is very efficient for most administrative tasks like installing packages, copying configuration files, or starting services. However, in complex modern infrastructure orchestration, we often need to coordinate between servers. For example, before upgrading a web server, we must disable it on the load balancer server first, create a new DNS entry on the main DNS server, or record the deployment log on our local control node. For multi-host scenarios like these, we need the Delegation and Local Action features.
Basic Concepts of Execution Delegation #
Execution delegation in Ansible is managed using the delegate_to directive. This directive tells Ansible to run a specific task on another host we designate, instead of on the target host currently being processed by the play queue.
Although the task executes on another host (the delegated host), the very important thing to understand is that the variable and facts context still refers to the original target host. That means variables like inventory_hostname, ansible_default_ipv4, or host-specific variables defined in host_vars still carry data belonging to the web server we’re upgrading, not the load balancer server where the delegated command executes.
Here’s a flow diagram comparing the execution flow without delegation vs the execution flow with delegation:
flowchart TD
subgraph "Normal Flow ("Without Delegation")"
C1["Control Node"] -->|"Send & Execute Module"| W1["Web Server 1"]
C1 -->|"Send & Execute Module"| W2["Web Server 2"]
end
subgraph "Delegation Flow ("With delegate_to: Load Balancer")"
C2["Control Node"] -->|"Send Instruction (Web Server 1 Variables)"| LB["Load Balancer Server"]
LB -->|"Execute Action for Web Server 1"| LB
C2 -->|"Send Instruction (Web Server 2 Variables)"| LB
LB -->|"Execute Action for Web Server 2"| LB
endWith delegation, we can easily create playbooks that bridge inter-server communication dynamically during the deployment process.
local_action and delegate_to: localhost #
In many automation scenarios, we need the control node (the machine where we run the ansible-playbook command) to perform an action. Examples include:
- Sending HTTP POST notifications to Slack, Discord, or Microsoft Teams webhooks.
- Downloading sensitive configuration files from an external Vault to the control node before distribution.
- Waiting for specific TCP ports to be up on managed nodes from the control node’s perspective (ensuring no firewall blocking).
- Recording deployment history to a local log file on the control node.
Ansible provides two ways to direct execution to the control node: delegate_to: localhost and local_action. Both are functionally identical, but have different syntax writing formats.
delegate_to: localhost vs local_action Syntax #
The delegate_to: localhost syntax is the modern recommended approach because it keeps the task writing structure consistent with regular modules. Meanwhile, local_action is a legacy syntax where the module name is placed as the first argument of the local_action directive.
Let’s look at the implementation difference between both through an anti-pattern and solution writing example:
# ANTI-PATTERN: Using a complicated local curl shell script delegated unnecessarily
- name: Send a slack notification manually
hosts: app_servers
tasks:
- name: Send a slack message via local shell
ansible.builtin.shell: "curl -X POST -H 'Content-type: application/json' --data '{\"text\":\"Deploying to {{ inventory_hostname }}\"}' https://hooks.slack.com/services/XXX"
delegate_to: localhost
# DON'T: Using raw shell commands breaks idempotency and makes error handling difficult.
# CORRECT: Using the clean 'uri' module with delegate_to: localhost
- name: Send the slack notification optimally
hosts: app_servers
tasks:
- name: Send a slack message via the uri module
ansible.builtin.uri:
url: https://hooks.slack.com/services/XXX
method: POST
body_format: json
body:
text: "Ansible successfully configured the server: {{ inventory_hostname }}"
delegate_to: localhost # ✓ Executing the uri module locally from our control node
If we want to write the same thing using local_action, the format looks like this:
# CORRECT: Using the alternative local_action syntax
- name: Record a local deployment log
hosts: app_servers
tasks:
- name: Write to the local log file
local_action:
module: ansible.builtin.lineinfile
path: /var/log/ansible_runs.log
line: "Host {{ inventory_hostname }} successfully configured at {{ ansible_date_time.iso8601 }}"
create: true
# ✓ The lineinfile module runs on localhost
Although local_action is still fully supported, we’re advised to use delegate_to: localhost for code readability by other team members.
delegate_facts for Distributing Information Between Hosts #
By default, if a task is delegated to another host using delegate_to, the facts collected by that task (for example if we run the setup module or a module registering new variables) are still stored under the original target host name in the hostvars memory variable.
However, sometimes we deliberately delegate a task to fetch specific information from the delegated host and we want that information stored as facts belonging to that delegated host so other hosts can use it later. To achieve this scenario, we use the delegate_facts: true directive.
delegate_facts Use Case #
Imagine we’re configuring a group of web servers (web_servers) that need the internal IP address of a database master located in another server group (db_servers). We can delegate the IP lookup task to that database server, store its facts directly on that database server, then access them from our web servers.
Here’s the practical implementation:
# CORRECT: Collecting the IP from the database server and distributing it to web servers using delegate_facts
---
- name: Connect the Web Servers with the Database Cluster
hosts: web_servers
vars:
db_host_target: "db-master.our.internal"
tasks:
- name: Collect facts from the database server in a delegated manner
ansible.builtin.setup:
filter: ansible_default_ipv4
delegate_to: "{{ db_host_target }}"
delegate_facts: true # ✓ Storing the default_ipv4 facts to db-master.our.internal, not to web_servers
run_once: true # Just run it once, no need to repeat for every web server
- name: Write the database configuration file on all web servers
ansible.builtin.template:
src: db_config.j2
dest: /var/www/html/config.php
mode: '0600'
vars:
# We access the facts belonging to db-master.our.internal filled by the previous task
database_ip: "{{ hostvars[db_host_target]['ansible_default_ipv4']['address'] }}"
Without delegate_facts: true, the database IP facts above would be stored under the first web host processing that task, which confuses other developers when re-reading our template code because the variables are disorganized.
Production Case Study: Load Balancer Cordon and Uncordon #
Let’s discuss a real-world case study very often encountered in DevOps production environments: Zero-Downtime Rolling Upgrade.
We have a web application cluster with 3 servers behind an HAProxy load balancer. If we stop the web service and deploy to all servers simultaneously, our application users experience downtime. To prevent this, we must use the rolling upgrade technique:
- Cordon: Contact the load balancer to disable new traffic to Server A.
- Connection Draining: Wait a few seconds so active HTTP connections on Server A finish processing naturally.
- Upgrade: Perform application code updates, migrations, and service restarts on Server A.
- Health Check: Do a local verification that the application port on Server A responds correctly.
- Uncordon: Re-enable Server A on the load balancer to start accepting new traffic.
- Repeat steps 1-5 for Server B and Server C in rotation.
This rolling upgrade process is visualized through the following sequence diagram:
sequenceDiagram
participant CN as Control Node
participant LB as HAProxy Load Balancer
participant WS as Web Server (Target)
CN->>LB: Cordon (Disable traffic to the Web Server via delegate_to)
Note over LB: HAProxy changes the target server status to MAINT
CN->>CN: Connection Draining (Wait 10 seconds)
CN->>WS: Deploy & Upgrade the Application (New code & Service Restart)
CN->>WS: Local Health Check (Ensure the webapp port responds 200 OK)
WS-->>CN: Healthy (200 OK)
CN->>LB: Uncordon (Re-enable the Web Server via delegate_to)
Note over LB: HAProxy changes the target server status to READYHere’s a complete, functional production Ansible playbook example for running the scenario above by manipulating the HAProxy statistics API in a delegated manner:
# CORRECT: Rolling upgrade playbook with HAProxy load balancer cordon/uncordon using delegate_to
---
- name: Zero-Downtime Application Rolling Upgrade
hosts: web_servers
serial: 1 # ✓ Very important! Executing servers one by one in rotation
vars:
app_port: 8080
lb_control_host: "lb-prod.our.internal"
lb_backend_name: "be_webapp"
app_dir: "/var/www/webapp"
tasks:
- name: 1. CORDON - Disable the server on the HAProxy Load Balancer
ansible.builtin.uri:
url: "http://{{ lb_control_host }}:9000/stats"
method: POST
user: admin
password: securepassword123
force_basic_auth: true
body_format: form-urlencoded
body:
# We send the backend/server command to enter maintenance mode (MAINT)
s: "{{ lb_backend_name }}/{{ inventory_hostname }}"
action: disable
delegate_to: "{{ lb_control_host }}" # ✓ The API action is called from the load balancer server itself
changed_when: true
- name: 2. DRAINING - Give active connections time to finish
ansible.builtin.pause:
seconds: 15
# This pause step runs locally on the control node to pause the transition process
- name: 3. UPGRADE - Pull the latest application code from the repository
ansible.builtin.git:
repo: "[email protected]:us/webapp.git"
dest: "{{ app_dir }}"
version: "tags/v2.1.5"
- name: 4. UPGRADE - Run internal dependency installation
ansible.builtin.command: npm install --production
args:
chdir: "{{ app_dir }}"
- name: 5. UPGRADE - Restart the webapp application service daemon
ansible.builtin.systemd:
name: webapp
state: restarted
- name: 6. HEALTH CHECK - Ensure the local application is up and running correctly
ansible.builtin.uri:
url: "http://127.0.0.1:{{ app_port }}/health"
status_code: 200
register: local_health
until: local_health.status == 200
retries: 6
delay: 5
# This task ensures we don't put a broken server back into the load balancer
- name: 7. UNCORDON - Re-enable the server on the HAProxy Load Balancer
ansible.builtin.uri:
url: "http://{{ lb_control_host }}:9000/stats"
method: POST
user: admin
password: securepassword123
force_basic_auth: true
body_format: form-urlencoded
body:
# Re-enabling the server to exit MAINT mode into READY mode
s: "{{ lb_backend_name }}/{{ inventory_hostname }}"
action: enable
delegate_to: "{{ lb_control_host }}" # ✓ Sent back to the load balancer
changed_when: true
The playbook above shows the power of delegate_to combined with serial: 1. Every time the loop processes one server, it pulls itself out of the global traffic circulation, performs safe installation, verifies its own health locally, and re-enters the load balancer cluster without ever disturbing active users.
run_once for Execution Efficiency #
By default, if a play covers 20 servers, every task in that play runs 20 times (once for each server). However, some operations are global and only need to execute exactly once for the entire play cycle. Classic examples are:
- Running database migration scripts (for example
django-admin migrateorflyway migrate). We only need to run it from one application server, not from 20 servers simultaneously which would actually corrupt the database schema integrity. - Creating a global release folder on centralized storage systems (S3 or NAS).
- Sending one email or Slack notification at the start of the deployment process to inform the team that the automation process has begun.
To save execution time and prevent crashes from duplicate actions, we can attach the run_once: true directive to that task.
How Does run_once Choose the Host? #
When run_once: true is declared without additional parameters, Ansible by default picks the first host registered in the active inventory to execute that task. We can also combine run_once with delegate_to to force that single execution to happen on a specific host, for example on the master database server or on the localhost control node.
Here’s a comparison of the anti-pattern of writing global tasks without run_once vs the correct solution:
# ANTI-PATTERN: Running the database migration on all hosts.
# This causes write races (race condition) and risks corrupting the database cluster!
- name: Web server deployment
hosts: app_servers
tasks:
- name: Run the database schema migration
ansible.builtin.command: /var/www/webapp/bin/db-migrate
# DON'T: Without run_once, this task is called by every server in app_servers in parallel.
# CORRECT: Using run_once to limit the migration to a single execution
- name: Optimal web server deployment
hosts: app_servers
tasks:
- name: Run the database schema migration only once
ansible.builtin.command: /var/www/webapp/bin/db-migrate
run_once: true # ✓ Guarantees it's only run by the first host in app_servers
# We can also add delegate_to if we want it processed from a specific node
Below is an example of combining run_once and delegate_to to globally record the release start time on the central monitoring server:
# CORRECT: Combining run_once with delegate_to
- name: Large system deployment
hosts: web_servers
tasks:
- name: Notify the monitoring server that the deployment started
ansible.builtin.uri:
url: "http://monitoring.our.internal/api/events"
method: POST
body_format: json
body:
event: "Deployment started"
cluster: "web-prod"
run_once: true # ✓ Only send 1 notification event, not 50 events!
delegate_to: localhost # ✓ Sent directly from the control node
Summary #
delegate_todirects task execution to another specified host, but still carries and maintains the variable and facts context belonging to the original target host (inventory_hostname).local_actionanddelegate_to: localhostare used to execute tasks locally on the control node, like sending Slack notifications, writing local audit logs, or waiting for external ports to be up.delegate_facts: trueforces facts collected from delegated tasks to be stored under the delegating host’s name in thehostvarsmemory, not under the play’s target host.- The combination of
serial: 1withdelegate_to: load_balanceris the industry-standard architecture pattern for building zero-downtime deployments (Cordon, Upgrade, Health Check, Uncordon).run_once: truelimits task execution to just once for all hosts in the play, crucial for preventing race conditions on global tasks like database schema migrations.