Introduction

Running Ansible against 10 hosts is easy. Running it against 1,000+ hosts requires deliberate performance tuning. This guide covers every lever: connection optimization, parallelism, fact caching, pull mode, execution environments, and architecture patterns that let Ansible scale to enterprise fleet sizes.

Baseline Performance Settings

# ansible.cfg — optimized for large fleets
[defaults]
forks = 50
timeout = 30
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 86400
stdout_callback = default
callback_whitelist = timer, profile_tasks
interpreter_python = auto_silent
host_key_checking = False

[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o PreferServerAlive=yes -o ServerAliveInterval=15
control_path_dir = /tmp/.ansible-cp

Key Settings Explained

SettingDefaultRecommendedImpact
forks550-100Parallel host connections
pipeliningFalseTrueReduces SSH operations by ~2x
gatheringimplicitsmartCache facts, skip if cached
fact_cachingmemoryjsonfile/redisPersist facts across runs
ControlPersist60s60s-300sReuse SSH connections

Optimize Parallelism

Forks Tuning

# Scale forks based on controller resources
# Rule of thumb: 1 fork per 100MB RAM, max ~200
# 16GB RAM → forks = 100
# 32GB RAM → forks = 200

[defaults]
forks = 100
# Override per-run
ansible-playbook site.yml -f 200

# Or per-play
# In playbook — override forks for specific plays
- name: Quick check across all hosts
  hosts: all
  strategy: free
  tasks:
    - name: Ping all hosts
      ansible.builtin.ping:

Strategy Plugins

# Linear (default) — waits for all hosts per task
- hosts: all
  strategy: linear

# Free — each host runs independently (fastest)
- hosts: all
  strategy: free

# Host pinned — reduces memory for large inventories
- hosts: all
  strategy: host_pinned
# ansible.cfg — use free strategy globally
[defaults]
strategy = free

Fact Caching with Redis

# Deploy Redis for fact caching
- name: Deploy fact cache
  hosts: ansible_controllers
  become: true
  tasks:
    - name: Install Redis
      ansible.builtin.package:
        name: redis-server
        state: present

    - name: Start Redis
      ansible.builtin.service:
        name: redis-server
        state: started
        enabled: true

    - name: Configure ansible.cfg for Redis caching
      community.general.ini_file:
        path: /etc/ansible/ansible.cfg
        section: defaults
        option: "{{ item.key }}"
        value: "{{ item.value }}"
      loop:
        - { key: fact_caching, value: redis }
        - { key: fact_caching_connection, value: "localhost:6379:0" }
        - { key: fact_caching_timeout, value: "86400" }
# ansible.cfg — Redis fact caching
[defaults]
fact_caching = redis
fact_caching_connection = localhost:6379:0
fact_caching_timeout = 86400
# Install: pip install redis

Reduce Gather Facts Overhead

# Only gather what you need
- hosts: all
  gather_facts: false
  tasks:
    - name: Gather only network facts
      ansible.builtin.setup:
        gather_subset:
          - network
      when: need_network_info | default(false)

    - name: Skip facts entirely for simple tasks
      ansible.builtin.command: uptime
# Or use gather_subset globally
- hosts: all
  gather_facts: true
  module_defaults:
    ansible.builtin.setup:
      gather_subset:
        - "!all"
        - "!min"
        - network
        - hardware

SSH Connection Optimization

# ansible.cfg — aggressive SSH optimization
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=300s -o PreferServerAlive=yes -o ServerAliveInterval=15 -o Compression=no
control_path_dir = /tmp/.ansible-cp
transfer_method = piped
retries = 3

Mitogen (Alternative SSH Transport)

# Install Mitogen for 2-7x speed improvement
pip install mitogen

# Configure
# ansible.cfg
# [defaults]
# strategy_plugins = /path/to/mitogen/ansible_mitogen/plugins/strategy
# strategy = mitogen_linear

Pull Mode for Massive Scale

# Deploy ansible-pull for 1000+ nodes
- name: Configure ansible-pull
  hosts: all
  become: true
  tasks:
    - name: Install Ansible on targets
      ansible.builtin.pip:
        name: ansible-core
        state: present

    - name: Deploy ansible-pull cron job
      ansible.builtin.cron:
        name: "Ansible pull"
        minute: "{{ 59 | random(seed=inventory_hostname) }}"
        hour: "*"
        job: >
          ansible-pull
          -U {{ playbook_repo }}
          -i {{ inventory_file }}
          -d /opt/ansible-pull
          --clean
          --only-if-changed
          >> /var/log/ansible-pull.log 2>&1

    - name: Deploy ansible-pull inventory
      ansible.builtin.template:
        src: pull-inventory.yml.j2
        dest: /etc/ansible/pull-inventory.yml

When to Use Pull vs Push

CriteriaPush (default)Pull (ansible-pull)
Hosts< 500500+
NetworkGood connectivityIntermittent/NAT
ControlCentralizedDistributed
LatencyLowHigher (cron-based)
ComplexitySimpleMore setup

Batch and Serial Execution

# Roll out changes in batches
- hosts: web_servers
  serial: "20%"
  max_fail_percentage: 10
  tasks:
    - name: Deploy application
      ansible.builtin.include_role:
        name: deploy

# Or use absolute numbers
- hosts: all
  serial:
    - 1      # Canary
    - 5      # Small batch
    - "25%"  # Quarter
    - "100%" # Everything else

Monitor Execution Performance

# ansible.cfg — enable profiling
[defaults]
callbacks_enabled = timer, profile_tasks, profile_roles

# Output shows time per task:
# TASK [Install packages] ****
# ok: [host1] => {"changed": false}
# Wednesday 13 April 2026  10:00:00 +0000 (0:00:45.123)
# Custom callback for logging to file
- hosts: all
  tasks:
    - name: Record execution metrics
      ansible.builtin.set_stats:
        data:
          execution_time: "{{ ansible_date_time.epoch }}"
          hosts_processed: "{{ ansible_play_hosts | length }}"

Execution Environments (EE)

# execution-environment.yml
version: 3
dependencies:
  galaxy: requirements.yml
  python: requirements.txt
  system: bindep.txt

build_arg_defaults:
  ANSIBLE_GALAXY_CLI_COLLECTION_OPTS: "--pre"

images:
  base_image: quay.io/ansible/ansible-runner:latest
# Build and use
ansible-builder build --tag my-ee:latest
ansible-navigator run site.yml --eei my-ee:latest

Architecture for 1,000+ Nodes

┌──────────────────────────────┐
│  AAP Controller / AWX        │
│  (Web UI + API + scheduling) │
└──────────┬───────────────────┘
           │
    ┌──────┴──────┐
    ▼             ▼
┌────────┐  ┌────────┐
│ Hop 1  │  │ Hop 2  │  ← Execution nodes
│ 500    │  │ 500    │     (spread load)
│ hosts  │  │ hosts  │
└────────┘  └────────┘

Redis ─── Fact cache (shared)
Git   ─── Playbook source of truth

Conclusion

Scaling Ansible to 1,000+ nodes requires tuning at every layer: increase forks to 50-100, enable SSH pipelining and ControlPersist, cache facts in Redis, use strategy: free for independent tasks, gather only the facts you need, and consider ansible-pull for massive fleets. For enterprise scale, AAP/AWX adds execution nodes that distribute the load. The key insight: Ansible's default settings are optimized for correctness and safety, not speed — tuning them for your environment can deliver 5-10x performance improvements with no playbook changes.