In this episode we'll learn to optimize Ansible execution speed starting from forks parallelism, SSH pipelining, fact caching, to the linear, free, and serial execution strategies for rolling updates.

After episode 14, where we covered protecting secrets with Ansible Vault, your playbooks are now not only complete, but also secure: credentials and API keys are stored encrypted and ready for use in CI/CD pipelines. But there's one problem that appears once infrastructure scales up: execution speed.
In previous episodes, you probably only ran playbooks against 2-3 lab servers, so 1-2 minutes of execution time wasn't noticeable. Now imagine the same thing applied to 200 servers in production. Each server needs at least one SSH connection per task, and for gather_facts (the setup module), Ansible has to open a new SSH connection each time a playbook runs. The result? A simple playbook that used to take 2 minutes could explode to 30-40 minutes. In the real working world, this execution time is usually fenced in by a change window or maintenance window — an agreed time window (usually midnight) during which infrastructure changes are allowed. If the playbook isn't finished within that window, you have to cancel or postpone the changes — and that's not a pleasant scenario.
In episode 15, we'll cover two big things: performance tuning — ways to speed up execution without changing playbook logic — and execution strategies — ways to control how tasks are distributed to many hosts. This is material you must master before scaling up to thousands of hosts.
The first principle of any optimization is: don't guess, measure. Before blindly changing ansible.cfg, you need to know how long the playbook runs and which task is slowest. The simplest way is wrapping the ansible-playbook command with the time command:
time ansible-playbook -i inventory.yml playbook.ymlThe final result will look like this — notice the PLAY RECAP block and the three time numbers at the bottom:
PLAY RECAP *********************************************************************
web01 : ok=7 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=7 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
db01 : ok=7 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
real 2m34.512s
user 0m1.432s
sys 0m0.298sThe real number is the total time you actually wait. But PLAY RECAP doesn't tell us which task takes the longest. For that, enable the profile_tasks callback:
[defaults]
callbacks_enabled = profile_tasksOnce enabled, each playbook run's end shows a duration report per task, starting from the slowest:
Friday 07 August 2026 10:24:11 +0000 (0:00:00.547) 0:02:34.512 *********
===============================================================================
Gathering Facts --------------------------------------------------------- 0:01:04.223
Install Nginx ------------------------------------------------------------ 0:00:45.110
Copy website content ----------------------------------------------------- 0:00:31.891
Render virtualhost template ---------------------------------------------- 0:00:09.445
Start & enable Nginx ----------------------------------------------------- 0:00:03.843From the report above, Gathering Facts consumes more than a minute of the total 2.5 minutes. This is a very common pattern — and the good news is, it's actually the easiest part to optimize, as we'll discuss in the fact caching section.
Tip
Get into the habit of recording a baseline (initial duration) before changing configuration, then comparing after each optimization. You can only prove an optimization worked if there's a number to compare against, not just "it feels faster".
forks ParameterWhen Ansible runs a playbook against many hosts, it doesn't contact all hosts at once. By default, Ansible only processes 5 hosts in parallel. The parameter controlling this is forks in the [defaults] section of ansible.cfg.
Think of forks like the number of toll payment lanes. The more lanes opened, the more cars (hosts) can be served at the same time. But opening too many lanes without enough resources just makes the queue at each lane slower — the same goes for the SSH connections Ansible opens.
[defaults]
forks = 30
strategy = linearHere's a guide for choosing the forks value:
forks Value | When It Fits |
|---|---|
5 | Ansible's built-in default; enough for labs and experiments |
10 – 20 | Fleets of dozens of hosts with a standard control node |
30 – 50 | Hundreds of hosts, control node with adequate multi-core CPU |
100+ | Thousands of hosts; must monitor CPU, memory, and file descriptor limits |
Warning
An overly large forks is not without risk. Each fork means an open Python process + SSH connection on the control node. If you set forks = 200 on a small VM, what happens isn't faster execution, but pegged CPU, exhausted memory, and some connections failing (Connection timed out or Too many open files errors). Increase gradually and monitor top/htop on the control node.
One important thing that's often misunderstood: forks controls the number of hosts processed concurrently for one task, not the total number of tasks running concurrently. With the linear strategy (default), all hosts must finish task 1 before any host starts task 2 — we dig deeper into this in the execution strategies section.
This is one of the optimizations with the biggest impact for playbooks running many tasks. To understand why, we have to see how Ansible executes a module without pipelining:
/tmp/ansible-tmp-...) using SFTP.Every task running on every host means one SSH connection + one SFTP upload. If there are 30 hosts × 10 tasks, that's 300 open-close connection and file-upload processes. This overhead is very real, especially on high-latency networks (e.g., servers spread across regions).
With SSH pipelining, Ansible no longer creates temporary files on the remote. The module's Python code is sent directly through the SSH connection's stdin, executed, then the result is returned — no SFTP upload and no files to clean up. The effect: SSH round-trips per task drop drastically, and for playbooks with many tasks, execution time can fall 20-50%.
[defaults]
pipelining = True
forks = 30Important
Pipelining carries one important requirement that often confuses people: requiretty on the sudo configuration must be disabled. When pipelining is active, sudo commands run without a TTY (because the input comes from a pipe). If /etc/sudoers on the managed node enables Defaults requiretty, sudo refuses to run without a terminal and the playbook will fail with a message like the one below.
fatal: [web01]: FAILED! => {"msg": "sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper"}The solution is making sure the /etc/sudoers file (or files in /etc/sudoers.d/) does not enable requiretty. Because modern distros like Ubuntu, Debian, and RHEL 8+ already don't enable requiretty by default, this is usually only an issue on older servers or configurations descended from old templates. You can fix it with:
sudo grep requiretty /etc/sudoers /etc/sudoers.d/ 2>/dev/null || echo "requiretty TIDAK aktif"If there's a Defaults requiretty line, remove or comment it out, then save. Alternatively, you can turn off pipelining only for specific hosts via the ansible_ssh_pipelining: false inventory variable — but that step should be a last resort, not a habit.
# ansible.cfg
[defaults]
pipelining = True
+forks = 30
-forks = 5Note
The ansible_ssh_pipelining variable can also be set per host/group in the inventory for cases where part of the fleet doesn't support pipelining (e.g., network servers without a full interactive shell). Set false only for the problematic group; let the rest enjoy the speedup.
From the profile_tasks report example above, Gathering Facts is the most expensive part. Every time a playbook runs with gather_facts: true (Ansible's default), the ansible.builtin.setup module runs on every host to collect hundreds of system facts — CPU architecture, memory, disk, OS, IP, and so on — which we learned about in episode 7. These facts rarely change, but Ansible still collects them every time, even if you only change one variable value.
There are two strategies to solve this problem:
First, turn off gathering if it's really not used. If your playbook never touches ansible_facts at all, add gather_facts: false at the play level:
---
- name: Restart service tanpa perlu facts
hosts: webservers
gather_facts: false
become: true
tasks:
- name: Restart Nginx
ansible.builtin.service:
name: nginx
state: restartedThis removes the setup cost entirely. But there's one consequence to remember: once gather_facts: false, variables like ansible_facts['ansible_default_ipv4'] are no longer available.
Second, enable fact caching. If your playbook really needs facts, don't collect them repeatedly — save the results in a cache. Ansible provides several cache backends; the two most popular are JSON file (no extra dependencies) and Redis (for large scale).
[defaults]
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 86400
gathering = smart
forks = 30fact_caching = jsonfile selects the JSON file cache backend.fact_caching_connection specifies the directory where cache files are stored.fact_caching_timeout is the cache lifetime in seconds (86400 = 24 hours).gathering = smart makes Ansible only run setup if there isn't already a valid cache.For the Redis backend, just change these two lines:
[defaults]
fact_caching = redis
fact_caching_connection = localhost:6379:0Comparison of the two:
| Aspect | jsonfile | redis |
|---|---|---|
| Extra setup | None | Needs a Redis server + the Python redis library on the control node |
| Storage medium | One JSON file per host in a local directory | Key-value in Redis memory |
| Read speed | Fair (disk I/O) | Very fast (in-memory) |
| Suitable for | Small-medium fleets, single control node | Large fleets, multi control node, HA |
| Failure risk | One corrupted file only affects one host | Redis down → all cache lost |
Tip
A side effect of fact caching that's often unnoticed: facts from other hosts can now be accessed from anywhere (not just the host itself). This opens up a very useful pattern, e.g., a playbook that runs something on host web01 but needs db01's IP address via hostvars['db01']['ansible_default_ipv4']['address'] — even if db01 isn't in the same play.
Now we get to the second part of this episode: execution strategies. If forks sets how many hosts are processed concurrently, a strategy sets how Ansible distributes tasks to those hosts.
linear Strategy (Default)The linear strategy is Ansible's default. All hosts run in "formation": task 1 must be completed by all hosts before any host starts task 2. Think of an exam class: no student may work on the next question before the whole class finishes the previous one.
The advantage: its behavior is very predictable, and it's safe for playbooks that depend on task order across hosts (e.g., the second task on web01 relies on the first task on db01 being done).
The disadvantage: the fastest host must wait for the slowest host at every task. If one of 30 servers is slow due to load, the whole fleet slows down with it.
---
- name: Update semua server, urutan terjamin
hosts: all
strategy: linear
become: true
tasks:
- name: Update package list
ansible.builtin.apt:
update_cache: true
- name: Upgrade semua paket
ansible.builtin.apt:
upgrade: distfree StrategyThe free strategy releases that formation. Each host works through the entire task list as fast as it can, without waiting for other hosts. A fast host finishes tasks 1-5 while a slow host is still on task 1 — that's allowed.
---
- name: Bersihkan cache di semua server, tiap host mandiri
hosts: all
strategy: free
become: true
tasks:
- name: Bersihkan cache package manager
ansible.builtin.shell:
cmd: apt-get clean
- name: Bersihkan log berumur lebih dari 30 hari
ansible.builtin.shell:
cmd: find /var/log -name "*.log" -mtime +30 -deleteWhen does free make sense? When the tasks are independent per host and you have a heterogeneous fleet (some servers much faster than others). Real examples: log cleanup, pulling per-server statistics, or benchmarking.
When is free dangerous? When there are order dependencies between hosts. If your playbook relies on the fact that all hosts have passed a certain task before anyone proceeds, the free strategy will destroy that guarantee and can trigger hard-to-trace race conditions.
A quick comparison of the available strategies:
| Strategy | Waits between hosts? | When to use |
|---|---|---|
linear | Yes (barrier at each task) | Default; task order between hosts matters |
free | No | Independent hosts, heterogeneous fleet, self-contained per-host tasks |
host_pinned | No | Like free, but tasks run sequentially per host (compatibility with free) |
debug | Yes | Debugging mode; allows executing tasks one by one interactively |
Caution
The free strategy also changes handlers behavior (episode 6): with free, a handler can execute at any point once the task that notified it finishes — not guaranteed to run at the end of the play as with linear. Don't combine free with logic sensitive to service restart/reload ordering.
serial: Rolling Updates in WavesThe third strategy isn't really a separate strategy — serial is a play parameter that works on top of the chosen strategy, and its function is limiting how many hosts may process a play at one time. Think of serial like setting the entrance capacity: only a handful of hosts come in and finish the entire play, then the next batch follows.
This is the foundation of the rolling update — a deployment pattern that keeps services available. Instead of restarting NGINX on 100 servers at once (which means total downtime if the config is wrong), you do 20% of servers first, verify, then continue.
---
- name: Rolling update NGINX - 20% server per batch
hosts: webservers
serial: 20%
become: true
tasks:
- name: Update & restart Nginx
ansible.builtin.apt:
name: nginx
state: latest
notify: Restart Nginx
handlers:
- name: Restart Nginx
ansible.builtin.service:
name: nginx
state: restartedserial: 20% divides 100 servers into 5 batches of 20. The play runs fully on the first batch, then the second, and so on. The percentage is calculated from the number of hosts in the play.serial: 1 is the most conservative mode: one server fully completes (including handlers) before the next one starts. This eliminates total downtime, but is also the slowest.serial: [1, 10, 50] is progressive batching: start with 1 server (to test), then 10, then 50. This pattern is popular because it provides a "safety valve" — if the first 1 server fails, the playbook stops before touching the rest.You can combine serial with max_fail_percentage to cancel the playbook automatically if failures in one batch exceed the threshold:
---
- name: Rolling update dengan guard failure
hosts: webservers
serial: 20%
max_fail_percentage: 30
become: trueImportant
With serial, PLAY RECAP will appear multiple times — once for each batch. Don't think it's a bug. Also remember that serial controls play execution as a whole, so all tasks (including handlers) on the first batch must complete before the second batch starts. That's what makes it ideal for zero-downtime deployments: you can verify the first batch is genuinely healthy before the rest gets updated.
Here's a complete ansible.cfg combining all the optimizations we discussed, plus a quick reference table:
[defaults]
forks = 30
pipelining = True
strategy = linear
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 86400
gathering = smart
callbacks_enabled = profile_tasksansible.cfg Option | Example Value | Function |
|---|---|---|
forks | 30 | Number of hosts processed in parallel per task |
pipelining | True | Send modules via SSH stdin, no temp files (requires requiretty disabled) |
fact_caching | jsonfile / redis | Facts cache backend so setup doesn't repeat |
fact_caching_connection | /tmp/ansible_facts_cache | Cache directory location / connection DSN |
fact_caching_timeout | 86400 | Cache lifetime in seconds |
gathering | smart | Run setup only if the cache isn't valid |
strategy | linear / free | Default strategy for the entire playbook |
callbacks_enabled | profile_tasks | Duration report per task |
So your tuning experience doesn't end in a migraine, here's a list of the most common mistakes seen in the field:
1. Enabling pipelining without disabling requiretty. This is the most common cause of "privilege escalation prompt" failures after pipelining = True. Check /etc/sudoers on the managed node first.
2. Setting forks huge without capacity. forks = 500 doesn't make Ansible faster if the control node only has 2 cores. Start at 20-30 and measure with profile_tasks.
3. gather_facts on when it's not used. The gather_facts: true default habit makes every playbook pay the setup cost. Set gather_facts: false for plays that don't need facts.
4. Fact caching with too short a timeout. If fact_caching_timeout is only 300 seconds, the cache almost always expires before being reused, and you keep gathering facts endlessly — nothing saved.
5. Using free on order-sensitive playbooks. Tasks with cross-host dependencies become prone to race conditions.
6. Forgetting that serial executes the play repeatedly. The number of PLAY RECAP notifications equals the number of batches; understand this so you don't panic seeing repeated output.
In episode 15, we covered how to make Ansible far more efficient at scale. You learned to measure performance with time and the profile_tasks callback, tune parallelism with forks, enable SSH pipelining (plus the mandatory requiretty requirement), and avoid repeated fact gathering with fact caching based on both jsonfile and redis. Finally, we explored execution strategies: linear for guaranteed ordering, free for self-contained hosts, and serial — the main weapon for downtime-free rolling updates.
Key points to take home:
profile_tasks) before optimizing; optimization without data is just guessing.forks and pipelining are the two highest-impact settings in ansible.cfg.serial: 20% or serial: 1 is the industry-standard pattern for rolling deployments.free strategy is powerful, but use it only for truly per-host independent tasks.However, there's one class of problems that no tuning can solve: tasks that genuinely take very long — large-scale OS upgrades, database migrations, or multi-terabyte backups. Running such tasks synchronously almost certainly makes the SSH connection time out, and the result is an ambiguous failure. In episode 16, we'll cover Asynchronous Actions & Polling — Ansible's mechanism for running long tasks in the background with the async and poll parameters, plus the fire-and-forget mode that frees playbooks from waiting. Keep your enthusiasm up!