Introduction

Ansible's execution strategy controls how tasks run across multiple hosts. The default linear strategy runs each task on all hosts before moving to the next. free lets each host proceed independently. serial limits how many hosts are updated at once — essential for zero-downtime deployments.

Strategy Types

Linear (Default)

Every host completes task 1 before any host starts task 2:

- hosts: webservers
  strategy: linear    # Default — can omit
  tasks:
    - name: Task 1    # Runs on ALL hosts first
      ...
    - name: Task 2    # Then ALL hosts run this
      ...
Host A: [Task 1] ─── wait ─── [Task 2] ─── wait ─── [Task 3]
Host B: [Task 1] ─── wait ─── [Task 2] ─── wait ─── [Task 3]
Host C: [Task 1] ─── wait ─── [Task 2] ─── wait ─── [Task 3]
         ▲ sync point          ▲ sync point

Free Strategy

Each host proceeds at its own pace:

- hosts: webservers
  strategy: free
  tasks:
    - name: Task 1
      ...
    - name: Task 2
      ...
Host A: [Task 1] [Task 2] [Task 3] ──── done
Host B: [Task 1] ──── [Task 2] ──── [Task 3] ── done
Host C: [Task 1] [Task 2] ──── [Task 3] ── done
         ▲ no sync points, each host runs independently

Use free when:

  • Tasks are independent per host
  • Hosts have different speeds
  • You want maximum parallelism

Serial (Rolling Updates)

Process hosts in batches:

# Update 2 hosts at a time
- hosts: webservers
  serial: 2
  tasks:
    - name: Deploy
      ...
Batch 1: [Host A, Host B] → run all tasks
Batch 2: [Host C, Host D] → run all tasks
Batch 3: [Host E]          → run all tasks

Serial with Percentages

# Update 25% of hosts at a time
- hosts: webservers
  serial: "25%"
  tasks: ...

Progressive Serial (Canary)

# Start small, scale up
- hosts: webservers
  serial:
    - 1        # First: 1 host (canary)
    - 5        # Then: 5 hosts
    - "25%"    # Then: 25% at a time
  tasks: ...

This is perfect for canary deployments — test on 1 host, then 5, then roll out to the rest.

max_fail_percentage

Stop the deployment if too many hosts fail:

- hosts: webservers
  serial: 5
  max_fail_percentage: 20    # Stop if >20% fail in a batch
  tasks:
    - name: Deploy application
      ...
# Absolute number
- hosts: webservers
  serial: 10
  any_errors_fatal: true    # Stop on ANY failure
  tasks: ...

Practical Patterns

Zero-Downtime Rolling Deploy

---
- hosts: webservers
  serial: 1                    # One host at a time
  max_fail_percentage: 0       # Zero tolerance
  
  pre_tasks:
    - name: Remove from load balancer
      ansible.builtin.uri:
        url: "https://lb.example.com/api/disable/{{ inventory_hostname }}"
        method: POST
        headers:
          Authorization: "Bearer {{ lb_token }}"
      delegate_to: localhost

  tasks:
    - name: Stop application
      ansible.builtin.service:
        name: myapp
        state: stopped

    - name: Deploy new version
      ansible.builtin.copy:
        src: "myapp-{{ version }}.jar"
        dest: /opt/myapp/

    - name: Start application
      ansible.builtin.service:
        name: myapp
        state: started

    - name: Wait for health check
      ansible.builtin.uri:
        url: http://localhost:8080/health
        status_code: 200
      retries: 30
      delay: 5

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

Canary Deployment

---
- hosts: webservers
  serial:
    - 1        # Canary: 1 host
    - "100%"   # If canary passes, all remaining
  max_fail_percentage: 0
  
  tasks:
    - import_tasks: tasks/deploy.yml
    
    - name: Run smoke tests
      ansible.builtin.command: /opt/myapp/smoke-test.sh
      delegate_to: localhost

Database Migration (Single Run)

# Run migration on ONE host only
- hosts: dbservers
  serial: 1
  tasks:
    - name: Run migration
      ansible.builtin.command: /opt/app/migrate.sh
      run_once: true
      
    - name: Restart all app servers
      ansible.builtin.service:
        name: myapp
        state: restarted

Maintenance Window

# Fast parallel for maintenance tasks
- hosts: all
  strategy: free
  gather_facts: false
  tasks:
    - name: Apply security patches
      ansible.builtin.apt:
        upgrade: safe
        update_cache: true

    - name: Reboot if needed
      ansible.builtin.reboot:
      when: reboot_required_file.stat.exists

Comparison

Featurelinearfreeserial
Sync between tasksYesNoYes (per batch)
Host independenceNoYesPer batch
Rolling updateNoNoYes
Canary deployNoNoYes
SpeedMediumFastestSlowest (safest)
Best forGeneral useIndependent tasksProduction deploys

Throttle (Per-Task Limit)

# Limit concurrency for a specific task
- name: Hit API (rate limited)
  ansible.builtin.uri:
    url: https://api.example.com/deploy
  throttle: 3    # Max 3 concurrent

Troubleshooting

Serial Too Slow

Increase batch size:

# Instead of serial: 1
serial: "20%"    # 20% of hosts at a time

Free Strategy Causes Race Conditions

If tasks depend on other hosts' state, use linear:

# Free is dangerous when tasks depend on each other
strategy: linear    # Use when tasks have cross-host dependencies

Conclusion

Use linear (default) for most playbooks. Use free when tasks are independent and you want speed. Use serial for production deployments — it's the key to zero-downtime rolling updates. Combine serial with progressive batches (1, 5, 25%) for canary deployments. Always set max_fail_percentage in production to stop bad deployments early. Use throttle to limit concurrency on specific tasks (API rate limits).