Ansible run_once vs delegate_to vs serial: Control Task Execution Scope
By Luca Berton · Published 2024-01-01 · Category: database-automation
Complete comparison of Ansible run_once, delegate_to, and serial. Learn when to run tasks on a single host, delegate to a specific target, or control batch.
run_once, delegate_to, and serial all control where and how often tasks execute — but they solve completely different problems. Confusing them causes duplicate operations, missed hosts, or broken rolling updates.
Quick Comparison
| Feature | run_once | delegate_to | serial | |---------|----------|-------------|--------| | What it does | Run task once for entire play | Run task on a different host | Process hosts in batches | | Scope | Play level | Task level | Play level | | Other hosts | Skip the task | Still run (on delegate target) | Wait for batch to finish | | Common use | DB migrations, downloads | Load balancer, monitoring | Rolling deployments | | Variables from | First host in batch | Original host (not delegate) | Current batch hosts |
See also: Ansible run_once: Execute Tasks Once Across All Hosts (Complete Guide)
run_once: Execute Once for All Hosts
run_once: true runs a task on the first host only, skipping it for all other hosts.
- name: Deploy to web cluster
hosts: webservers # web01, web02, web03, web04
tasks:
- name: Run database migration (once only)
ansible.builtin.command: /opt/app/bin/migrate up
run_once: true
# Runs on web01 only, skips web02/03/04
- name: Deploy application code
ansible.builtin.copy:
src: app.tar.gz
dest: /opt/app/
# Runs on ALL hosts
run_once with serial
When using serial, run_once runs once per batch, not once for the entire play:
- hosts: webservers # web01, web02, web03, web04
serial: 2
tasks:
- name: Download artifact
ansible.builtin.get_url:
url: "https://releases.example.com/app-{{ version }}.tar.gz"
dest: /tmp/app.tar.gz
run_once: true
# Runs on web01 (batch 1) AND web03 (batch 2)
# ⚠️ Not truly "once" — once per batch!
Common run_once Patterns
# Database migration
- ansible.builtin.command: /opt/app/bin/migrate
run_once: true
# Download shared artifact
- ansible.builtin.get_url:
url: "{{ artifact_url }}"
dest: /tmp/artifact.tar.gz
run_once: true
delegate_to: localhost # Download to Ansible controller
# Send deployment notification
- ansible.builtin.uri:
url: "https://slack.webhook.url"
method: POST
body: '{"text": "Deploying {{ version }}"}'
run_once: true
delegate_to: Run on a Different Host
delegate_to executes a task on a different host than the current play target, but uses variables from the original host.
- name: Rolling update with load balancer
hosts: webservers
serial: 1
tasks:
- name: Remove from load balancer
ansible.builtin.uri:
url: "http://lb.example.com/api/pool/web/members/{{ inventory_hostname }}"
method: DELETE
delegate_to: lb.example.com
# Runs on lb.example.com, but {{ inventory_hostname }} is the current web server
- name: Deploy application
ansible.builtin.copy:
src: app.tar.gz
dest: /opt/app/
- name: Add back to load balancer
ansible.builtin.uri:
url: "http://lb.example.com/api/pool/web/members"
method: POST
body: '{"host": "{{ inventory_hostname }}", "port": 8080}'
delegate_to: lb.example.com
delegate_to: localhost
# Run a command on the Ansible controller
- name: Add DNS record
community.general.cloudflare_dns:
zone: example.com
record: "{{ inventory_hostname }}"
type: A
value: "{{ ansible_default_ipv4.address }}"
delegate_to: localhost
# Wait for a port from the controller's perspective
- name: Wait for service to be reachable
ansible.builtin.wait_for:
host: "{{ inventory_hostname }}"
port: 8080
timeout: 60
delegate_to: localhost
Variable Context with delegate_to
- hosts: webservers # web01 (10.0.1.1), web02 (10.0.1.2)
tasks:
- name: Register on monitoring server
ansible.builtin.lineinfile:
path: /etc/monitoring/targets.conf
line: "{{ inventory_hostname }} {{ ansible_default_ipv4.address }}"
delegate_to: monitoring.example.com
# Runs on monitoring.example.com
# But inventory_hostname = web01/web02 (original host)
# And ansible_default_ipv4 = 10.0.1.1/10.0.1.2 (original host facts)
delegate_facts
To store gathered facts on the delegate target instead of the original host:
- name: Gather facts from monitoring server
ansible.builtin.setup:
delegate_to: monitoring.example.com
delegate_facts: true
# Facts stored under hostvars['monitoring.example.com'], not current host
See also: How to Do Rolling Updates with Zero Downtime in Ansible
serial: Batch Execution
serial processes hosts in batches, completing all tasks on one batch before starting the next.
# Process 2 hosts at a time
- hosts: webservers # 8 hosts
serial: 2
tasks:
- name: Stop service
ansible.builtin.systemd:
name: myapp
state: stopped
- name: Deploy code
ansible.builtin.copy:
src: app.tar.gz
dest: /opt/app/
- name: Start service
ansible.builtin.systemd:
name: myapp
state: started
# Batch 1: web01, web02 (stop → deploy → start)
# Batch 2: web03, web04 (stop → deploy → start)
# Batch 3: web05, web06 ...
# Batch 4: web07, web08 ...
Percentage-Based serial
# Process 25% of hosts at a time
- hosts: webservers
serial: "25%"
Ramping serial (Canary Pattern)
# Start with 1 host, then ramp up
- hosts: webservers
serial:
- 1 # First: 1 host (canary)
- 5 # Then: 5 hosts
- "100%" # Finally: all remaining
tasks:
- name: Deploy and verify
ansible.builtin.include_tasks: deploy.yml
max_fail_percentage
# Abort if more than 30% of hosts in a batch fail
- hosts: webservers
serial: 5
max_fail_percentage: 30
tasks:
- name: Deploy
ansible.builtin.include_tasks: deploy.yml
Combining All Three
- name: Zero-downtime rolling deployment
hosts: webservers
serial: 2 # 2 hosts at a time
max_fail_percentage: 25
tasks:
- name: Announce deployment start
ansible.builtin.uri:
url: "{{ slack_webhook }}"
method: POST
body: '{"text": "Starting batch deploy"}'
run_once: true # Once per batch
- name: Drain connections on load balancer
ansible.builtin.uri:
url: "http://lb/api/drain/{{ inventory_hostname }}"
method: POST
delegate_to: lb.example.com # Run on LB, using webserver vars
- name: Wait for connections to drain
ansible.builtin.wait_for:
timeout: 30
- name: Deploy application
ansible.builtin.copy:
src: "releases/{{ version }}.tar.gz"
dest: /opt/app/
- name: Restart service
ansible.builtin.systemd:
name: myapp
state: restarted
- name: Verify health
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}:8080/health"
register: health
until: health.status == 200
retries: 10
delay: 5
delegate_to: localhost # Check from controller
- name: Re-enable on load balancer
ansible.builtin.uri:
url: "http://lb/api/enable/{{ inventory_hostname }}"
method: POST
delegate_to: lb.example.com
See also: Ansible run_once: Execute Task on Single Host (Complete Guide)
FAQ
Does run_once guarantee execution on the first host?
It runs on the first host in the current batch (as determined by inventory order). With serial, it runs once per batch, not once for the entire play. If you need truly once, use run_once: true with delegate_to: localhost.
Can I delegate_to a host not in my inventory?
Yes, but Ansible won't have facts for it unless you gather them explicitly. The host must be reachable via SSH (or the appropriate connection plugin). Add it to inventory for best results.
What happens if a host fails in a serial batch?
The remaining tasks for that host are skipped. If max_fail_percentage is exceeded, the entire play aborts. Otherwise, the next batch proceeds.
Can I use serial with free strategy?
No. serial requires the default linear strategy (or host_pinned). The free strategy lets hosts proceed independently, which conflicts with batch processing.
Conclusion
run_once for tasks that should execute once (migrations, notifications). delegate_to for tasks that target a different host (load balancers, monitoring, DNS). serial for batch processing (rolling updates, canary deploys). Combine all three for zero-downtime rolling deployments.
Related Articles
• Ansible serial Rolling Updates Guide • Ansible delegate_to Guide • how Ansible handlers fire on changes • Ansible Playbook Structure GuideCategory: database-automation