Introduction

delegate_to is an Ansible keyword that runs a task on a different host while keeping variables bound to the original play target. The task executes on the delegated host (localhost, a load balancer, database, or control point), but all variables still reference the original managed host.

This is essential for rolling updates with load balancer management, monitoring checks, DNS registration, centralized database operations, and any task that needs to run outside the primary play target while maintaining context.

Basic Usage

# Run on localhost instead of remote host
- name: Add host to load balancer
  ansible.builtin.command: /opt/lb/add-server {{ inventory_hostname }}
  delegate_to: lb.internal

# Run locally
- name: Wait for SSH
  ansible.builtin.wait_for:
    host: "{{ ansible_host }}"
    port: 22
    timeout: 300
  delegate_to: localhost

Common Patterns

Load Balancer Management

---
- name: Rolling update
  hosts: webservers
  serial: 1
  tasks:
    - name: Remove from load balancer
      ansible.builtin.uri:
        url: "https://{{ lb_host }}/api/servers/{{ inventory_hostname }}/disable"
        method: POST
        headers:
          Authorization: "Bearer {{ lb_token }}"
      delegate_to: localhost

    - name: Deploy new version
      ansible.builtin.shell: /opt/deploy.sh {{ app_version }}

    - name: Wait for healthy
      ansible.builtin.uri:
        url: "http://localhost:8080/health"
        status_code: 200
      retries: 10
      delay: 5
      register: health
      until: health.status == 200

    - name: Add back to load balancer
      ansible.builtin.uri:
        url: "https://{{ lb_host }}/api/servers/{{ inventory_hostname }}/enable"
        method: POST
        headers:
          Authorization: "Bearer {{ lb_token }}"
      delegate_to: localhost

Monitoring / Alerting

- name: Silence monitoring during maintenance
  ansible.builtin.uri:
    url: "https://monitoring.internal/api/v1/silences"
    method: POST
    body_format: json
    body:
      matchers:
        - name: instance
          value: "{{ inventory_hostname }}"
      startsAt: "{{ ansible_date_time.iso8601 }}"
      endsAt: "{{ '%Y-%m-%dT%H:%M:%S' | strftime(ansible_date_time.epoch | int + 3600) }}"
      comment: "Ansible maintenance"
  delegate_to: localhost

# ... do maintenance ...

- name: Remove silence
  ansible.builtin.uri:
    url: "https://monitoring.internal/api/v1/silence/{{ silence_id }}"
    method: DELETE
  delegate_to: localhost

Database Operations from App Server

- name: Run database migration
  ansible.builtin.command: /opt/myapp/migrate.sh
  delegate_to: "{{ groups['dbservers'][0] }}"
  run_once: true

# Variables still reference the original host
- name: Create database for this host
  community.postgresql.postgresql_db:
    name: "app_{{ inventory_hostname | replace('-', '_') }}"
  delegate_to: db.internal

DNS Registration

- name: Register DNS entry
  community.general.nsupdate:
    server: ns1.internal
    zone: internal.example.com
    record: "{{ inventory_hostname }}"
    type: A
    value: "{{ ansible_default_ipv4.address }}"
  delegate_to: localhost

delegate_to: localhost

# These are equivalent:
- name: Local action
  ansible.builtin.command: echo "hello"
  delegate_to: localhost

- name: Local action (shorthand)
  local_action:
    module: ansible.builtin.command
    cmd: echo "hello"

# Common use: API calls, wait_for, local file operations
- name: Save host info locally
  ansible.builtin.copy:
    content: "{{ ansible_facts | to_nice_json }}"
    dest: "/tmp/facts/{{ inventory_hostname }}.json"
  delegate_to: localhost

delegate_to with delegate_facts

By default, facts from delegated tasks are assigned to the original host. Use delegate_facts: true to assign them to the delegated host:

# Facts saved to db.internal (not the play host)
- name: Gather DB server facts
  ansible.builtin.setup:
  delegate_to: db.internal
  delegate_facts: true

# Now use db.internal's facts
- debug:
    msg: "DB memory: {{ hostvars['db.internal']['ansible_memtotal_mb'] }}"

delegate_to with run_once

# Run migration once, on the DB server
- name: Database migration
  ansible.builtin.command: python manage.py migrate
  args:
    chdir: /opt/myapp
  delegate_to: "{{ groups['dbservers'][0] }}"
  run_once: true

# Send one notification (not per host)
- name: Notify Slack
  ansible.builtin.uri:
    url: "{{ slack_webhook }}"
    method: POST
    body_format: json
    body:
      text: "Deployment complete on {{ ansible_play_hosts | join(', ') }}"
  delegate_to: localhost
  run_once: true

Variable Context

Tasks run on the delegated host, but variables reference the original play host:

# Play targets webservers, task runs on localhost
- name: Add host to config
  ansible.builtin.lineinfile:
    path: /etc/haproxy/servers.cfg
    line: "server {{ inventory_hostname }} {{ ansible_host }}:{{ app_port }}"
  delegate_to: lb.internal
  # inventory_hostname = the web server
  # Task runs on lb.internal
  # Line references the web server's name and IP

Troubleshooting

"SSH connection failed" on Delegated Host

The delegated host must be reachable and in the inventory (or use add_host first):

# Add host dynamically
- ansible.builtin.add_host:
    name: lb.internal
    ansible_user: admin
    ansible_ssh_private_key_file: ~/.ssh/lb_key

# Now delegate to it
- name: Update LB
  ansible.builtin.command: /opt/lb/reload
  delegate_to: lb.internal

Connection Type for localhost

# Avoid SSH to localhost — use local connection
# In inventory:
# localhost ansible_connection=local

# Or in task:
- name: Local task
  ansible.builtin.command: echo "local"
  delegate_to: localhost
  vars:
    ansible_connection: local

Conclusion

delegate_to runs tasks on a different host while keeping variable context from the original host. Key uses: load balancer management during rolling updates, centralized database operations, DNS registration, monitoring silences, and local API calls. Combine with run_once: true for tasks that should execute only once regardless of host count. Use delegate_facts: true when gathering facts about the delegated host. Remember that delegate_to: localhost is the most common pattern for API calls and local file operations.