Introduction

Ansible normally runs every task on every host in the play. But sometimes you need to run a task once (database migration), run it on a different host (update load balancer from web server play), or roll out changes in batches (serial deployment). Three directives control this: run_once, delegate_to, and serial.

Quick Comparison

DirectiveEffectScopeCommon Use
run_onceExecute task on first host onlyTaskDB migrations, one-time setup
delegate_toExecute task on a different hostTaskLB updates, central API calls
serialLimit concurrent hosts per batchPlayRolling deployments

run_once — Execute Once

# Run database migration only once, not per-host
- name: Run database migration
  ansible.builtin.command: /opt/app/migrate.sh
  run_once: true
  # Runs on the FIRST host in the play
  # Other hosts skip this task but get the registered variable

# Common pattern: create shared resource
- name: Create S3 bucket
  amazon.aws.s3_bucket:
    name: "{{ app_bucket }}"
    state: present
  run_once: true

# run_once + delegate_to: run once on a specific host
- name: Update DNS record
  community.general.nsupdate:
    server: "{{ dns_server }}"
    record: "{{ app_domain }}"
    value: "{{ vip_address }}"
  run_once: true
  delegate_to: "{{ dns_server }}"

run_once Gotchas

# ⚠️ Registered variable IS available on all hosts
- name: Get schema version
  ansible.builtin.command: psql -c "SELECT version FROM schema"
  register: schema_version
  run_once: true
  changed_when: false

- name: Show on all hosts
  ansible.builtin.debug:
    msg: "Schema: {{ schema_version.stdout }}"
  # ✅ Works on all hosts — variable is shared

# ⚠️ run_once runs on the first host in the batch
# With serial, it runs once PER BATCH
- hosts: webservers
  serial: 5
  tasks:
    - name: Notify Slack
      ansible.builtin.uri:
        url: "{{ slack_webhook }}"
        body: '{"text": "Deploying batch..."}'
      run_once: true
      # Runs once per batch of 5, not once total!

delegate_to — Run Somewhere Else

# Remove host from load balancer BEFORE deploying
- name: Disable in HAProxy
  community.general.haproxy:
    state: disabled
    host: "{{ inventory_hostname }}"
    backend: web_backend
  delegate_to: "{{ lb_host }}"

# Deploy new code
- name: Deploy application
  ansible.builtin.unarchive:
    src: app.tar.gz
    dest: /opt/app/

# Add host back to load balancer AFTER deploying
- name: Enable in HAProxy
  community.general.haproxy:
    state: enabled
    host: "{{ inventory_hostname }}"
    backend: web_backend
  delegate_to: "{{ lb_host }}"

delegate_to localhost

# Run API calls from the controller
- name: Create CloudFlare DNS record
  ansible.builtin.uri:
    url: "https://api.cloudflare.com/client/v4/zones/{{ zone_id }}/dns_records"
    method: POST
    body_format: json
    body:
      type: A
      name: "{{ inventory_hostname }}"
      content: "{{ ansible_default_ipv4.address }}"
  delegate_to: localhost
  # API call happens from Ansible controller, using target host's facts

delegate_facts

# Gather facts FROM the delegated host (not default)
- name: Get load balancer facts
  ansible.builtin.setup:
  delegate_to: "{{ lb_host }}"
  delegate_facts: true
  # Facts are stored for lb_host, not the current host

serial — Batch Execution

# Deploy to 2 hosts at a time
- hosts: webservers
  serial: 2
  tasks:
    - name: Deploy app
      ansible.builtin.include_role:
        name: deploy

# Percentage-based
- hosts: webservers
  serial: "25%"

# Progressive batches (canary pattern)
- hosts: webservers
  serial:
    - 1       # Canary: deploy to 1 host first
    - 5       # Small batch
    - "50%"   # Half remaining
    - "100%"  # Everything else
  max_fail_percentage: 10  # Abort if >10% fail in any batch

Serial with Rolling Updates

- hosts: webservers
  serial: 1
  order: sorted  # Alphabetical order for predictability

  pre_tasks:
    - name: Remove from load balancer
      community.general.haproxy:
        state: disabled
        host: "{{ inventory_hostname }}"
      delegate_to: "{{ lb_host }}"

  tasks:
    - name: Update application
      ansible.builtin.include_role:
        name: deploy

    - name: Health check
      ansible.builtin.uri:
        url: "http://localhost:{{ app_port }}/health"
      register: health
      until: health.status == 200
      retries: 30
      delay: 5

  post_tasks:
    - name: Add back to load balancer
      community.general.haproxy:
        state: enabled
        host: "{{ inventory_hostname }}"
      delegate_to: "{{ lb_host }}"

Combining All Three

- hosts: webservers
  serial: 3  # 3 hosts at a time

  tasks:
    # Notify once per batch
    - name: Announce deployment batch
      ansible.builtin.uri:
        url: "{{ slack_webhook }}"
        body: '{"text": "Deploying batch {{ ansible_play_batch }}..."}'
      run_once: true
      delegate_to: localhost

    # Remove each host from LB
    - name: Drain from load balancer
      community.general.haproxy:
        state: disabled
        host: "{{ inventory_hostname }}"
      delegate_to: "{{ lb_host }}"

    # Deploy on each host
    - name: Deploy
      ansible.builtin.include_role:
        name: deploy

    # Re-add each host to LB
    - name: Enable in load balancer
      community.general.haproxy:
        state: enabled
        host: "{{ inventory_hostname }}"
      delegate_to: "{{ lb_host }}"

Common Mistakes

# ❌ Using run_once when you need delegate_to
- name: Update load balancer config
  ansible.builtin.template:
    src: haproxy.cfg.j2
    dest: /etc/haproxy/haproxy.cfg
  run_once: true
  # This runs on web1, not on the load balancer!

# ✅ Use delegate_to to target the right host
- name: Update load balancer config
  ansible.builtin.template:
    src: haproxy.cfg.j2
    dest: /etc/haproxy/haproxy.cfg
  delegate_to: "{{ lb_host }}"
  run_once: true

# ❌ Forgetting serial runs run_once per BATCH
# ❌ Using delegate_to without considering which host's variables apply

Conclusion

run_once: execute a task once across the play (DB migration, API call, notification). delegate_to: execute a task on a different host while keeping the current host's variables (load balancer updates, central API calls). serial: control how many hosts run at once for safe rolling deployments. The classic rolling-update pattern combines all three: serial for batched rollout, delegate_to for load balancer control, and run_once for per-batch notifications.