You’re managing a deployment pipeline that needs to set up 50 servers for a new application release. You start your Ansible playbook, expecting it to finish in a few minutes, but an hour later, it’s still stuck on the first few tasks.
This situation is more common than you might think. Many teams face slow Ansible playbooks that turn quick configuration changes into lengthy bottlenecks. Interestingly, most performance issues arise from a few common problems that are surprisingly easy to fix.
In this article, we’ll explore proven strategies to optimize Ansible playbook execution. We will also discuss a range of methods, including straightforward configuration adjustments that can substantially reduce execution time.
Prerequisites
To follow the examples in this article, you’ll need:
- A basic understanding of Ansible and the YAML syntax
- Ansible is installed on your control machine (version 2.9 or later recommended)
- Access to at least one target server for testing optimisations
Before diving into advanced optimizations, let’s start with the foundation: how Ansible connects to your servers. Most performance issues begin here, and fortunately, the fixes are straightforward.
SSH pipelining
Ansible’s default SSH settings prioritize compatibility over speed. However, you can safely turn on more aggressive optimizations in most production environments.
One of the most effective ways to enhance performance is by minimizing SSH connection overhead. Ansible establishes multiple SSH connections for each task, which can result in significant delays in high-latency environments. SSH pipelining enhances efficiency by creating a single connection for each host and running multiple commands over it.
Here’s how to configure it:
# ansible.cfg
[ssh_connection]
pipelining = True
These settings work together to create persistent SSH connections that are reused across multiple tasks. Instead of establishing a new connection for each task, Ansible uses a single connection per host, significantly lowering overhead.
SSH connection multiplexing
SSH connection multiplexing improves performance by reusing current SSH connections, minimizing the necessity for new ones. Utilizing a single TCP connection for multiple SSH sessions to the same host diminishes the overhead of establishing and negotiating a new connection each time.
The ssh_args
parameter is used to set this up in the ansible.cfg
file.
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=80s
The settings above maintain SSH connections for 80 seconds after use, eliminating the overhead of repeatedly establishing connections to the same hosts. Adjust the ControlPersist
value according to the playbook frequency. A value of 60 – 80 seconds will work for short-lived playbooks, and 300 seconds for long-lived playbooks.
Inventory best practices
Poor inventory design can slow down playbook execution. Let’s explore when to use the two most common types of inventory, static and dynamic, and how to use them effectively.
Static inventories offer faster parsing and less overhead for stable, known environments. Dynamic inventories are best used selectively for cloud environments or large, frequently changing infrastructures.
For large static inventories, organize hosts into logical groups and use inventory patterns to target specific subsets:
[webservers]
web01.example.com ansible_host=192.168.1.10
web02.example.com ansible_host=192.168.1.11
web03.example.com ansible_host=192.168.1.12
web04.example.com ansible_host=192.168.1.13
web05.example.com ansible_host=192.168.1.14
[databases]
db01.example.com ansible_host=192.168.2.10
db02.example.com ansible_host=192.168.2.11
[production:children]
webservers
databases
For example, you can test your playbook on a subset of web servers before full production rollout:
# Test on first 2 webservers only
ansible-playbook deploy.yml --limit 'webservers[0:1]'
Implement inventory caching for dynamic inventories to reduce API calls and improve performance:
[inventory]
cache = True
cache_plugin = memory
cache_timeout = 1800
This configuration caches inventory data for 30 minutes, significantly reducing the time spent on inventory resolution for subsequent playbook runs.
Another key advantage of dynamic inventory is that failed or offline hosts are automatically excluded, preventing your playbooks from hanging on unreachable targets.
How you organize your playbooks can determine whether they run in a straightforward sequence or achieve real parallel processing. Let’s look at strategies that can turn sluggish playbooks into efficient automation.
Strategy plugins
Ansible’s default linear strategy means all hosts must complete a task before any host can start the next one. This creates unnecessary waiting time when tasks have different execution speeds across hosts.
- hosts: all
strategy: free
tasks:
- name: Update package cache
apt: update_cache=yes
- name: Install independent packages
package:
name: "{{ item }}"
state: present
loop:
- curl
- wget
- vim
The free
strategy allows faster hosts to continue executing tasks while slower hosts catch up. This is particularly effective when you have a mix of server types or when some hosts have higher latency.
Task grouping with blocks
Organizing related tasks into blocks not only improves readability but also enables better error handling and conditional execution:
- name: Web server configuration
block:
- name: Install web server packages
package:
name: "{{ item }}"
state: present
loop: "{{ web_packages }}"
- name: Configure web server
template:
src: httpd.conf.j2
dest: /etc/httpd/conf/httpd.conf
notify: restart httpd
- name: Start and enable web server
service:
name: httpd
state: started
enabled: true
when: "'webservers' in group_names"
This approach prevents unnecessary task execution on hosts that don’t match the conditions, saving both time and resources.
One of the most effective ways to speed up playbook execution is to run tasks in parallel rather than sequentially. Understanding Ansible’s parallelism options becomes crucial here.
Increase fork count
By default, Ansible employs a conservative limit of five process forks for task execution. However, most control hosts can support many more than this. Raising the fork count enables Ansible to manage multiple hosts simultaneously.
# ansible.cfg
[defaults]
forks = 20
The optimal fork count depends on your control machine’s resources and network capacity. A good starting point is 2-4 times your CPU core count, but monitor system resources and adjust accordingly.
For environments with 50-100 hosts, setting forks to 15-25 typically provides good performance. Larger environments might benefit from 30-50 forks, provided the control node has sufficient resources.
Asynchronous task execution
For long-running tasks, async execution prevents the entire playbook from waiting for a single slow operation:
- name: Long-running backup operation
command: /usr/local/bin/backup-database.sh
async: 1800 # 30 minutes timeout
poll: 0 # Don't wait, continue immediately
register: backup_job
- name: Continue with other tasks
package:
name: monitoring-agent
state: present
- name: Check backup completion
async_status:
jid: "{{ backup_job.ansible_job_id }}"
register: backup_result
until: backup_result.finished
retries: 180 # Check for 30 minutes
delay: 10 # Check every 10 seconds
This pattern is beneficial for tasks like software installations, database migrations, or file transfers that can run independently.
Every module call adds overhead, so choosing the correct module and minimizing unnecessary operations can significantly impact performance. Selecting the right Ansible modules and utilizing them efficiently is crucial for making playbooks run faster. Too many module calls can really slow things down.
Choose native modules over shell commands
Native Ansible modules are optimized for their specific tasks and often perform better than generic shell commands:
# Inefficient - multiple shell commands
- name: Create directory structure
shell: |
mkdir -p /opt/app/config
mkdir -p /opt/app/logs
mkdir -p /opt/app/data
chmod 755 /opt/app/config
chown app:app /opt/app/data
# Efficient - native modules
- name: Create application directories
file:
path: "{{ item.path }}"
state: directory
mode: "{{ item.mode | default('0755') }}"
owner: "{{ item.owner | default('root') }}"
group: "{{ item.group | default('root') }}"
loop:
- { path: '/opt/app/config' }
- { path: '/opt/app/logs' }
- { path: '/opt/app/data', owner: 'app', group: 'app' }
Native modules also provide better error handling, idempotency, and cross-platform compatibility.
Check out the list of the most useful Ansible modules.
Batch operations efficiently
When working with multiple similar operations, look for opportunities to batch them:
# Instead of multiple package installations
- name: Install all required packages at once
package:
name:
- nginx
- postgresql
- redis
- nodejs
state: present
# Instead of multiple file copies
- name: Copy configuration files
copy:
src: "{{ item }}"
dest: "/etc/app/{{ item | basename }}"
mode: '0644'
loop:
- app.conf
- database.conf
- cache.conf
By default, Ansible gathers facts for each host at the beginning of every play, unless gather_facts
is set. Fact gathering can be time-consuming, especially when dealing with multiple hosts, but it’s often unnecessary for simple tasks.
Strategic fact gathering
Control which facts are gathered and when:
# Disable facts for simple tasks
- hosts: all
gather_facts: no
tasks:
- name: Simple service restart
service:
name: nginx
state: restarted
# Gather only specific facts when needed
- hosts: databases
gather_facts: yes
gather_subset:
- "!all" # Exclude everything
- "!min" # Exclude minimal facts
- network # Include only network facts
- hardware # Include only hardware facts
tasks:
- name: Configure database based on memory
template:
src: my.cnf.j2
dest: /etc/mysql/my.cnf
vars:
db_memory: "{{ ansible_memory_mb.real.total * 0.7 | int }}MB"
Enable fact caching
Fact caching can dramatically reduce repeated fact-gathering overhead:
# ansible.cfg
[defaults]
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 3600
This is particularly useful in small environments with fewer than 100 servers. However, jsonfile
caching requires you to clean up the cache manually. You can take an extra step to automate the cleanup with a scheduled cron job.
# Clean up JSON fact cache older than 7 days daily at 2 AM
0 2 * * * find /tmp/ansible_fact_cache/ -type f -mtime +7 -delete
For production environments, consider using Redis for fact caching:
# ansible.cfg
[defaults]
fact_caching = redis
fact_caching_connection = redis://localhost:6379/0
fact_caching_timeout = 7200 # 120 miutes TTL
fact_caching_prefix = ansible_facts_
Redis caching includes TTL (Time to Live) settings that automatically manage stale cache.
Complex template processing and external lookups can create significant bottlenecks, primarily when performed repeatedly within loops.
Optimise variable processing
Pre-process complex variables outside of loops:
# Inefficient - complex processing in loop
- name: Process configuration files
template:
src: "{{ item.name }}.j2"
dest: "/etc/app/{{ item.name | regex_replace('[^a-zA-Z0-9]', '_') | lower }}.conf"
loop: "{{ config_files }}"
vars:
processed_name: "{{ item.name | regex_replace('[^a-zA-Z0-9]', '_') | lower }}"
# Efficient - pre-process variables
- name: Pre-process configuration names
set_fact:
processed_configs: "{{ config_files | map('combine', {'clean_name': item.name | regex_replace('[^a-zA-Z0-9]', '_') | lower}) | list }}"
- name: Deploy configuration files
template:
src: "{{ item.name }}.j2"
dest: "/etc/app/{{ item.clean_name }}.conf"
loop: "{{ processed_configs }}"
In the example above, we are trying to use names to create a file. The names need to be cleaned (i.e., only letters and numbers, all lowercase).
The inefficient block tries to clean each name on each loop, which is repetitive, harder to maintain, and can ultimately impair performance. The efficient block utilizes set_fact
parameter to clean all the names before looping through them.
Cache external lookups
When dealing with external data sources, cache the results:
- name: Fetch external configuration once
uri:
url: "{{ config_api_endpoint }}"
method: GET
return_content: yes
register: external_config
run_once: true
delegate_to: localhost
- name: Apply configuration to all hosts
template:
src: app_config.j2
dest: /etc/app/config.json
vars:
api_config: "{{ external_config.json }}"
In the block above, we fetched data from an external API and used it across all hosts. This prevents repeated HTTP calls, which can slow down the playbook’s execution.
Organizing tasks into well-structured roles makes it easier to maintain, allows reuse in different projects, and can boost performance with clearer logic and more efficient condition handling.
Role structure for performance
Using when conditions helps you avoid running installation tasks on systems where they simply won’t work. Instead of executing every task regardless of the target OS, you filter out the ones that don’t apply.
This saves time during execution and keeps your logs focused on what actually happened, rather than filled with predictable failures from incompatible operations.
# roles/web_server/tasks/main.yml
- name: Include OS-specific variables
include_vars: "{{ ansible_os_family | lower }}.yml"
- name: Install web server (RedHat family)
include_tasks: install_redhat.yml
when: ansible_os_family == "RedHat"
- name: Install web server (Debian family)
include_tasks: install_debian.yml
when: ansible_os_family == "Debian"
- name: Configure web server
include_tasks: configure.yml
Import vs include strategy
Ansible provides two distinct mechanisms for incorporating external task files: import_* and include_*.
Static Imports (import_*
) are processed during the parse phase, before playbook execution begins. While the Dynamic Includes (include_*
) are processed during runtime, as the playbook executes.
# Static import - processed at parse time (faster)
- import_tasks: always_needed_tasks.yml
# Dynamic include - processed at runtime (more flexible)
- include_tasks: "{{ ansible_distribution | lower }}_specific.yml"
when: ansible_distribution is defined
Static imports
are faster because there is no file I/O during execution; tasks are pre-loaded and ready, and no variable evaluation is needed for filenames. Dynamic includes
have overhead because they require file system access during execution, variables must be evaluated at runtime, and there’s additional processing for each include operation.
Static imports
are generally faster but less flexible, while dynamic includes
offer more control at the cost of some performance.
It is recommended to use imports_*
for fixed filenames and always-needed tasks, and include_*
for variable filenames and conditional loading.
Measurement is essential to optimize effectively. By enabling callback plugins like timer
, profile_tasks
, and profile_roles
, you can analyze the time taken by tasks and pinpoint which jobs are causing delays in your plays.
The timer
and profile_tasks
callback plugins display the start time and duration for every task, while profile_roles
provides timing information at the role level. These built-in profiling tools help pinpoint exactly which tasks consume the most execution time.
Enable built-in profiling
# ansible.cfg
[defaults]
callbacks_enabled = timer, profile_tasks, profile_roles
This configuration will show you exactly where your playbooks spend their time:
$ ansible-playbook site.yml
PLAY RECAP ********************************************************************
web01.example.com : ok=12 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Monday 05 June 2025 14:30:25 +0000 (0:00:02.45) 0:00:15.20 ************
Install packages -------------------------------------------------------- 5.23s
Configure web server ---------------------------------------------------- 3.45s
Start services ---------------------------------------------------------- 2.11s
Update package cache ---------------------------------------------------- 1.89s
Copy configuration files ------------------------------------------------ 1.32s
While useful, delegate_to
, local_action
, and run_once
can slow down performance if not used carefully.
These options modify the execution location and behaviour of a task. delegate_to
transfers execution to another host (commonly localhost
), local_action
is a shortcut for executing a task on the control node, and run_once
ensures a task is executed only once, typically on the first host.
These are useful when a task needs to run in one place, such as retrieving external data or creating a shared file. However, using them too often, especially in loops, prevents Ansible from running tasks concurrently.
Minimize delegation overhead
Instead of delegating multiple individual tasks:
# Inefficient - multiple delegations
- name: Create local backup directory
file:
path: /backups/{{ inventory_hostname }}
state: directory
delegate_to: backup-server
- name: Copy application logs
fetch:
src: /var/log/app.log
dest: /backups/{{ inventory_hostname }}/
delegate_to: backup-server
- name: Compress backup
archive:
path: /backups/{{ inventory_hostname }}
dest: /backups/{{ inventory_hostname }}.tar.gz
delegate_to: backup-server
Combine operations where possible:
# Efficient - batch operations
- name: Create backup script
template:
src: backup.sh.j2
dest: /tmp/backup_{{ inventory_hostname }}.sh
mode: '0755'
delegate_to: backup-server
- name: Execute backup script
command: /tmp/backup_{{ inventory_hostname }}.sh
delegate_to: backup-server
Optimize run_once Usage
You can run a task on a single host with run_once
, but use it strategically to avoid redundant operations while maintaining parallel execution for other tasks.
For example, you might fetch an API config once with delegate_to: localhost
and run_once, then apply that config to all other hosts in parallel:
- name: Fetch config
uri:
url: https://api.example.com/app-config
return_content: yes
register: config
run_once: true
delegate_to: localhost
- name: Apply config to hosts
template:
src: app_config.j2
dest: /etc/myapp/config.json
vars:
config_data: "{{ config.json }}"
When dealing with large inventories or complex variable sets, how you loop through and organize data can significantly impact execution speed and readability.
Choose the right loop type
Different loop types have different performance characteristics:
# Efficient for simple lists
- name: Install packages
package:
name: "{{ item }}"
state: present
loop: "{{ package_list }}"
# Efficient for dictionaries
- name: Create users
user:
name: "{{ item.key }}"
groups: "{{ item.value.groups }}"
shell: "{{ item.value.shell | default('/bin/bash') }}"
loop: "{{ users | dict2items }}"
# Efficient for complex data structures
- name: Configure services
service:
name: "{{ item.name }}"
state: "{{ item.state }}"
enabled: "{{ item.enabled | default(true) }}"
loop: "{{ services }}"
loop_control:
label: "{{ item.name }}" # Reduces verbose output
Optimize data structures
Structure your data in a way that allows for efficient access and processing.
For example, if you’re managing configurations for multiple services like nginx and Apache, organizing them as a dictionary lets you quickly find information based on the service name, without having to search through a list each time.
# Efficient lookup structure
services_config:
nginx:
port: 80
ssl_cert: "/etc/ssl/nginx.crt"
workers: "{{ ansible_processor_vcpus }}"
apache:
port: 8080
ssl_cert: "/etc/ssl/apache.crt"
workers: "{{ ansible_processor_vcpus * 2 }}"
# O(1) lookup instead of O(n) search through lists
- name: Configure service
template:
src: "{{ service_name }}.conf.j2"
dest: "/etc/{{ service_name }}/{{ service_name }}.conf"
vars:
service_config: "{{ services_config[service_name] }}"
Spacelift’s vibrant ecosystem and excellent GitOps flow are helpful for managing and orchestrating Ansible. By introducing Spacelift on top of Ansible, you can easily create custom workflows based on pull requests and apply any necessary compliance checks for your organization.
Another advantage of using Spacelift is that you can manage infrastructure tools like Ansible, Terraform, Pulumi, AWS CloudFormation, and even Kubernetes from the same place and combine their stacks with building workflows across tools.
You can bring your own Docker image and use it as a runner to speed up deployments that leverage third-party tools. Spacelift’s official runner image can be found here.
Our latest Ansible enhancements solve three of the biggest challenges engineers face when they are using Ansible:
- Having a centralized place in which you can run your playbooks
- Combining IaC with configuration management to create a single workflow
- Getting insights into what ran and where
Provisioning, configuring, governing, and even orchestrating your containers can be performed with a single workflow, separating the elements into smaller chunks to identify issues more easily.
Would you like to see this in action, or just get an overview? Check out this video showing you Spacelift’s Ansible functionality:
If you want to learn more about using Spacelift with Ansible, check our documentation, read our Ansible guide, or book a demo with one of our engineers.
Ansible performance optimization involves making smart changes that lead to big improvements. The most effective strategies focus on efficient connections, running tasks in parallel, and minimizing unnecessary work.
Important optimizations that provide fast results include using SSH pipelining, increasing fork counts, and selectively gathering facts. These simple changes often greatly reduce execution times. We also explored techniques such as asynchronous execution and callback plugins to address specific slowdowns in larger setups.
The examples showed how to apply each optimization, from simple ansible.cfg adjustments to reorganise playbooks for better speed. We explained how organizing roles and handling variables properly helps automation work well across different infrastructure sizes.
Remember that optimization is an ongoing process. As your infrastructure grows and changes, new bottlenecks will emerge. Regular monitoring and profiling should be part of your standard playbook development workflow.
Manage Ansible better with Spacelift
Managing large-scale playbook execution is hard. Spacelift enables you to automate Ansible playbook execution with visibility and control over resources, and seamlessly link provisioning and configuration workflows.