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
| Directive | Effect | Scope | Common Use |
|---|---|---|---|
run_once | Execute task on first host only | Task | DB migrations, one-time setup |
delegate_to | Execute task on a different host | Task | LB updates, central API calls |
serial | Limit concurrent hosts per batch | Play | Rolling 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
Related Articles
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.