Strategy & Serial #
By default Ansible runs each task on all hosts before moving to the next task. This is called the linear strategy — safe and predictable, but not always optimal. For deployments that want each server to run as fast as possible independently, or for rolling deployments that only update a few servers at a time, we need to understand how to control this parallel execution flow. This article discusses the three main Ansible strategies, using serial for rolling deployments, and throttle for limiting per-task concurrency. At the end, we’ll be able to choose the right combination for every deployment scenario — from full parallel for cloud provisioning to 1-server-at-a-time canary updates for critical services.
Comparison of the Three Strategies #
Before diving into details, first understand the differences between the three available strategies. The strategy choice determines when Ansible moves from one task to the next, and how different hosts synchronize with each other. This isn’t a choice that’s often changed, but the wrong decision can cause unnecessary downtime or hard-to-debug bugs.
| Strategy | Synchronization | Speed | Debug | Use case |
|---|---|---|---|---|
linear (default) | All hosts sync per task | Slow if there’s variation | Easy (all hosts at the same stage) | Standard configuration, normal deployment |
free | No synchronization | Fastest | Hard (hosts at different stages) | Independent per-host tasks, provisioning |
host_pinned | Sync per forks batch | Medium | Medium | When you need a parallelism limit + free execution within batches |
The following state diagram shows the visual difference between the three. Notice that linear is one parallel row with many sync points (T1, T2, T3), free is many parallel rows without sync points, and host_pinned is parallel rows grouped into batches:
stateDiagram-v2
[*] --> Linear: default
state Linear {
[*] --> T1_Sync: "Task 1 runs on all hosts"
T1_Sync --> T2_Sync: "All hosts finished"
T2_Sync --> T3_Sync: "All hosts finished"
T3_Sync --> [*]
}
state Free {
[*] --> Host1Run: "Host A runs continuously"
Host1Run --> Host2Run: "Host B runs independently"
Host2Run --> Host3Run: "Host C runs independently"
Host3Run --> [*]
}
state HostPinned {
[*] --> Batch1: "Batch 1 (5 hosts) runs"
Batch1 --> Batch2: "Batch 1 done, continue batch 2"
Batch2 --> [*]
}Linear strategy is the sensible default for most playbooks. It guarantees that if task 3 on host A needs task 2’s output, task 2 has already finished. But for specific scenarios — deployments to large clusters, cloud provisioning, or workloads that differ greatly between hosts — other strategies provide significant advantages.
Linear Strategy: The Safe Default #
The linear strategy runs one task on all hosts, waits for all to finish, then moves to the next task. This is Ansible’s default for good reasons: it provides high predictability. Every host goes through tasks in the same order, and log output is easy to follow because all hosts are on the same task.
# Default strategy: linear (doesn't need to be written explicitly)
- name: Configure all webservers
hosts: webservers
# strategy: linear # implicit
tasks:
- name: Update the package index
apt:
update_cache: yes
- name: Install nginx
apt:
name: nginx
state: present
- name: Copy the configuration
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
The ideal scenario for linear: application configuration, deployments needing atomic updates on all servers, or tasks with strong cross-task dependencies. Database cluster configuration, where every node must update its schema before other nodes may join, is a classic linear strategy example.
The downside of linear: if there are 100 hosts and 1 host is slow, all other hosts wait. Total execution time = (slowest task time) × (number of tasks). For a 100-server cluster with a 30-second task time, the deployment finishes in 5 minutes only because of 1 slow server. That’s what other strategies try to solve.
Free Strategy: Each Host Runs Continuously #
The free strategy frees every host from the obligation of waiting for other hosts. As soon as a host finishes task N, it immediately moves to task N+1, regardless of where other hosts are. This is ideal for tasks that don’t depend on other hosts’ output — provisioning, artifact downloads, or fully local configuration.
# playbooks/deploy-with-free.yml
---
- name: Deploy with the free strategy
hosts: webservers
strategy: free # Each host runs independently
tasks:
- name: Pull the code
git:
repo: https://github.com/company/app.git
dest: /opt/app
version: "{{ version }}"
- name: Install dependencies
pip:
requirements: /opt/app/requirements.txt
virtualenv: /opt/app/venv
# Each host immediately moves to this task after the pull finishes
# without waiting for other hosts to finish pulling
- name: Compile assets
command: npm run build
args:
chdir: /opt/app
ANTI-PATTERN vs CORRECT: Free vs Linear for Deployments #
# ANTI-PATTERN: linear for a deployment that could be free (losing 50% of time)
- name: Deploy to 100 servers
hosts: webservers
strategy: linear # All hosts wait for each other
tasks:
- name: Download the artifact (10 seconds on fast servers, 60 on slow ones)
get_url:
url: "https://artifacts.company.com/app-{{ version }}.tar.gz"
dest: /tmp/app.tar.gz
# Total time: 60 seconds (waiting for the slowest server)
# Then 100 hosts install in parallel on the next task
# CORRECT: free strategy for tasks that don't need synchronization
- name: Deploy to 100 servers
hosts: webservers
strategy: free # Each host moves on immediately
tasks:
- name: Download the artifact
get_url:
url: "https://artifacts.company.com/app-{{ version }}.tar.gz"
dest: /tmp/app.tar.gz
- name: Install (fast hosts start while others are still downloading)
unarchive:
src: /tmp/app.tar.gz
dest: /opt/app
remote_src: yes
# Total time: 60 seconds (slowest server) — BUT installation is parallel
# and there's no pause between tasks
The free strategy makes debugging harder. Because hosts can be on different tasks, when one host fails, we must know at which task that host failed relative to other hosts. Log output is also harder to follow. Use free only for playbooks that are already stable and tested, or add serial so batches are controlled.serial: Rolling Deployments #
serial controls how many hosts are processed at once in a single “batch”. After one batch finishes (all tasks on all hosts in the batch), Ansible moves to the next batch. This is the standard way to do safe rolling deployments: update a few servers at a time, verify, then continue.
# playbooks/rolling-deploy.yml
---
- name: Rolling deployment — 1 server at a time
hosts: webservers
serial: 1 # Update one server, wait for it to finish, then move to the next
tasks:
- name: Deploy the new code
git:
repo: https://github.com/company/app.git
dest: /opt/app
version: "{{ version }}"
- name: Restart the application
systemd:
name: myapp
state: restarted
- name: Verify health
uri:
url: "http://localhost:{{ app_port }}/health"
status_code: 200
retries: 6
delay: 5
serial can be a number, a percentage, or a list for gradual ramp-up:
# Serial as a percentage
- hosts: webservers
serial: "25%" # Update 25% of servers at once (e.g. 2 of 8 servers)
# Serial as a list: canary deployment
- hosts: webservers
serial:
- 1 # Batch 1: one server only (canary)
- "10%" # Batch 2: 10% of the remainder
- "50%" # Batch 3: 50% of the remainder
- "100%" # Batch 4: everything left
This list pattern is very useful for canary deployments — deploy to one server first, verify, then continue to more servers. This is the pattern used by Netflix, Google, and many large tech companies for deployments that must not fail.
Serial vs Linear Sequence Diagram #
To understand what happens internally with serial, see the following sequence diagram. Notice how serial: 2 splits 4 hosts into 2 batches, and each batch waits to finish before continuing:
sequenceDiagram
participant Ctrl as "Ansible Controller"
participant H1 as "Host 1"
participant H2 as "Host 2"
participant H3 as "Host 3"
participant H4 as "Host 4"
Note over Ctrl,H1: Batch 1 (serial=2)
Ctrl->>H1: Task 1
Ctrl->>H2: Task 1
H1-->>Ctrl: Done
H2-->>Ctrl: Done
Ctrl->>H1: Task 2
Ctrl->>H2: Task 2
H1-->>Ctrl: Done
H2-->>Ctrl: Done
Note over Ctrl,H3: Batch 2
Ctrl->>H3: Task 1
Ctrl->>H4: Task 1
H3-->>Ctrl: Done
H4-->>Ctrl: Done
Ctrl->>H3: Task 2
Ctrl->>H4: Task 2
H3-->>Ctrl: Done
H4-->>Ctrl: DoneThis sequence explains an important concept: with serial: 2 and 4 hosts, total execution time = 2 × (time per batch). With linear, all 4 hosts are processed in parallel in 1 batch. The trade-off: serial is safer (one failing batch doesn’t abort everything), but slower. For production deployments, this trade-off is usually very worthwhile.
The Canary Deployment Pattern #
Canary deployment is the safest deployment pattern for critical services. The basic idea: deploy to a small subset first, verify the service stays healthy, then deploy to more. If the canary fails, the deployment stops and only 1-2 servers are affected — not all of them.
# playbooks/canary-deploy.yml
---
- name: Canary deployment — stage 1 (1 server)
hosts: webservers
serial: 1
max_fail_percentage: 0 # Stop if the canary fails
tasks:
- name: Deploy to the canary server
git:
repo: https://github.com/company/app.git
dest: /opt/app
version: "{{ version }}"
- name: Restart the application
systemd:
name: myapp
state: restarted
- name: Wait and verify the canary
uri:
url: "http://localhost:{{ app_port }}/health"
status_code: 200
retries: 12
delay: 10
- name: Verify the canary error rate hasn't increased
uri:
url: "http://prometheus.internal/api/v1/query"
method: GET
body_format: form-urlencoded
body: >-
query=rate(http_requests_total{status=~"5..",instance="{{ inventory_hostname }}"}[5m])
register: error_rate
failed_when:
- error_rate.json.data.result | length > 0
- error_rate.json.data.result[0].value[1] | float > 0.01
- name: Deploy to the remaining servers after the canary is OK
hosts: webservers[1:] # All except the first server (canary)
serial: "25%"
tasks:
- name: Deploy to the remaining servers
git:
repo: https://github.com/company/app.git
dest: /opt/app
version: "{{ version }}"
- name: Restart the application
systemd:
name: myapp
state: restarted
The canary pattern has three elements, each crucial. First, serial: 1 for the first batch — deploy to one server only. Second, max_fail_percentage: 0 — stop the deployment if the canary fails, don’t roll out immediately. Third, verify the error rate from the metrics system (Prometheus, Datadog, etc.) — a health check alone isn’t enough because errors can happen in the background without surfacing in the health check.
Separate the canary and the rollout into two plays. The pattern above uses two separate plays — the first play for the canary with verification, the second for the rest. This ensures verification completes before the deployment continues. If we combine them into one play withserial: [1, "25%"], all tasks in the play stay in one play, andmax_fail_percentagemay not apply between batches.
throttle: Limiting Concurrency at the Task Level #
serial controls batches at the play level. throttle limits concurrency at the individual task level — useful for tasks that strain external resources. This difference is important: serial affects all tasks in the play, throttle only affects one task. Combining both gives granular control.
# playbooks/deploy-with-throttle.yml
---
- name: Deploy to all servers
hosts: webservers
strategy: free # Each host runs as fast as possible
tasks:
- name: Pull the code (can be fully parallel)
git:
repo: https://github.com/company/app.git
dest: /opt/app
version: "{{ version }}"
- name: Download the artifact from S3 (limit 5 simultaneous downloads)
aws_s3:
bucket: my-artifacts
object: "releases/{{ version }}/app.tar.gz"
dest: /tmp/app.tar.gz
mode: get
throttle: 5 # Maximum 5 hosts running this task at the same time
# Prevents S3 throttling or flooding the bandwidth
- name: Restart the application (can be fully parallel)
systemd:
name: myapp
state: restarted
ANTI-PATTERN vs CORRECT: Throttle for External Resources #
# ANTI-PATTERN: 100 hosts downloading in parallel from S3 — hit rate limits
- name: Download the artifact
aws_s3:
bucket: my-artifacts
object: "releases/{{ version }}/app.tar.gz"
dest: /tmp/app.tar.gz
mode: get
# Without throttle, S3 will throttle or the local bandwidth saturates
# CORRECT: throttle 5-10 simultaneous downloads
- name: Download the artifact
aws_s3:
bucket: my-artifacts
object: "releases/{{ version }}/app.tar.gz"
dest: /tmp/app.tar.gz
mode: get
throttle: 10
# Bandwidth stays balanced, S3 isn't throttled, the deployment stays fast
Throttle vs forks: forks (in ansible.cfg) controls Ansible’s global concurrency. throttle controls per-task. If we set forks = 50 and throttle: 5 on the S3 download task, a maximum of 50 hosts run in parallel, but only 5 download from S3 at once. This is very useful for distinguishing between “how many hosts Ansible processes at once” and “how many hosts may use the external resource at once”.
Don’t set throttle: 0 or remove it for production. The throttle default is the number of hosts in the play, which means without throttle all hosts run in parallel. This can bottleneck third-party APIs, S3 buckets, or databases. Always set an explicit throttle for tasks talking to shared external resources. For purely local tasks (files, systemd, package managers), throttle isn’t needed.max_fail_percentage: The Failure Tolerance Limit #
max_fail_percentage is a safety net for serial deployments. Without this parameter, Ansible continues to the next batch even if the previous batch failed completely. With max_fail_percentage: 0, the deployment stops at the first failure.
- name: Rolling deploy with failure tolerance
hosts: appservers
serial: 2
max_fail_percentage: 25 # Tolerate 25% of hosts failing per batch
tasks:
- name: Deploy the code
git:
repo: https://github.com/company/app.git
dest: /opt/app
version: "{{ version }}"
- name: Restart the application
systemd:
name: myapp
state: restarted
register: restart_result
until: restart_result is success
retries: 3
delay: 5
How max_fail_percentage works: Ansible calculates the percentage of hosts that failed in the current batch. If it exceeds the threshold, the entire play stops. For a 4-host batch with max_fail_percentage: 25, if 2 hosts fail, the play stops. For a 1-host batch (canary), the threshold must be 0 because 100% = 1 host = always above the 25 threshold.
run_once with Serial #
When using serial, a task with run_once only runs once in the first batch, not in every batch. This is useful for database migrations, DNS updates, or one-time operations that only need to happen once at the start of a deployment.
- name: Rolling deploy with a database migration
hosts: appservers
serial: 2
pre_tasks:
- name: Run the database migration (only once, at the start)
command: python manage.py migrate
run_once: true # Only in the first batch, first server
delegate_to: "{{ groups['appservers'][0] }}"
tasks:
- name: Deploy the code to all servers (per batch)
git:
repo: https://github.com/company/app.git
dest: /opt/app
version: "{{ version }}"
- name: Run the light migration per server
command: python manage.py migrate_light
# This runs on every server, every batch
What to notice: run_once inside a play with serial only runs once total in that play. If we want the migration to run per batch, remove run_once. The run_once + delegate_to combination is useful for operations that may only happen at one point (e.g. the primary database), not on every server.
Decision Tree for Choosing a Strategy #
With all the options available, we might be confused about when to use which. The following decision tree summarizes heuristics we can use as a starting point. Of course, every environment has its own constraints we must consider:
flowchart TD
Start["Deployment"] --> Q1{"Need zero-downtime?"}
Q1 -- "No" --> Linear["Linear strategy, no serial"]
Q1 -- "Yes" --> Q2{"Tasks independent per host?"}
Q2 -- "Yes" --> Free["Free strategy"]
Q2 -- "No" --> Q3{"High failure risk per server?"}
Q3 -- "Yes" --> Q4{"Can auto-rollback?"}
Q4 -- "No" --> Canary["serial: 1, max_fail_percentage 0"]
Q4 -- "Yes" --> Q5{"Critical production service?"}
Q5 -- "Yes" --> Canary2["serial: list, 1/10%/50%/100%"]
Q5 -- "No" --> Roll["serial: 25% or 50%"]
Q3 -- "No" --> RollStart conservative, loosen up once confident. A healthy pattern for deploying a new service: start with serial: 1 and max_fail_percentage: 0 until we’re sure the playbook is reliable. After several successful deployments without rollbacks, raise to serial: 10% or serial: 25%. For pure canary, serial: [1, "10%", "50%", "100%"] is the sweet spot for most services.
When You Don’t Need Strategy or Serial #
Finally, it’s important to know that not all playbooks need advanced strategies. For idempotent, low-risk tasks, the default linear + all hosts parallel is enough:
Use the DEFAULT (linear, all hosts parallel) for:
✓ Initial provisioning of new servers
✓ Security package updates across the fleet
✓ Non-critical service configuration
✓ Idempotent tasks with easy rollback
Use SERIAL + max_fail_percentage for:
✓ Application deployments to production
✓ Database schema updates
✓ Critical service restarts
✓ Changes needing per-batch verification
Use THROTTLE for:
✓ Downloads from S3 / object storage
✓ API calls to rate-limited services
✓ Tasks straining a shared database
✓ Provisioning to cloud APIs (AWS, GCP, Azure)
Use the FREE strategy for:
✓ Cloud provisioning with varying per-host times
✓ Deployments with fully independent tasks
✓ Per-host chained tasks (A → B → C where B needs A's output)
Summary #
linear(default): all hosts finish one task before continuing — easy to predict and debug, but fast hosts wait for slow ones. Suitable for standard configuration and normal deployments.free: each host runs as fast as possible without waiting for other hosts — fastest, but harder to debug because hosts can be at different stages. Suitable for fully independent tasks like cloud provisioning.host_pinned: like free but in batches — rarely used, usuallyforksis enough to limit concurrency.serial: 1for zero-downtime rolling deployments — update one server, verify health, then continue. Suitable for services needing per-server attention.serialas a list ([1, "10%", "50%", "100%"]) for canary deployments — gradual ramp-up with Prometheus metrics verification at every stage. The safest pattern for production.serialas a percentage ("25%") for a balance between speed and safety — suitable for most services.throttleto limit concurrency at the individual task level — use for S3 downloads, API calls to rate-limited services, or tasks straining a shared database.max_fail_percentage: 0together withserialto stop the deployment immediately if there’s a failure — no next batch if the previous one failed.run_once: truein a play withserialonly runs once in the first batch — useful for one-time database migrations or DNS updates.- Start conservative, loosen up once confident. A healthy pattern:
serial: 1+max_fail_percentage: 0for the first deploy, raise after the playbook proves reliable.- The
strategy: free+throttle: Ncombination on external tasks gives maximum speed without flooding shared resources — the best of both worlds.