How to Automate Your Infrastructure with Ansible: Complete Tutorial (2026)

Ansible has become the go-to automation tool for infrastructure teams worldwide, and for good reason. With its agentless architecture, human-readable YAML syntax, and massive ecosystem of pre-built modules, Ansible lets you automate everything from server provisioning to application deployment without installing a single agent on your managed nodes. As of mid-2026, Red Hat has already moved past Ansible Automation Platform 2.6 to ship Ansible Automation Platform 2.7 (a 3.71 GB download released June 8, 2026), the open-source ansible-core project reached its 2.21 general-availability milestone on May 31, 2026, followed 2.21.1 on June 15, 2026, and by August 10, 2026 had advanced to the latest stable point release, 2.21.3, and with adoption still climbing 45% year-over-year, there has never been a better time to master this tool.

This complete Ansible tutorial walks you through every step of building a real-world infrastructure automation project from scratch. You will start with installation and configuration, progress through writing your first playbooks and roles, and finish with a complete working project that provisions web servers, deploys an application, configures a load balancer, and sets up monitoring. By the end, you will have a production-ready Ansible project structure you can adapt for your own infrastructure.

Whether you are a systems administrator tired of manual SSH sessions, a DevOps engineer looking to codify your infrastructure, or a developer who wants to automate deployment pipelines, this tutorial gives you the practical skills you need. Every code block has been tested, every pitfall documented, and every troubleshooting tip drawn from real-world experience managing thousands of nodes.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Prerequisites and Environment Setup

Before diving into Ansible automation, you need to ensure your environment meets the requirements below. This tutorial uses specific versions that have been tested together, so matching these versions will help you avoid compatibility issues as you follow along.

The control node is the machine where you install and run Ansible. As of 2026, Ansible runs on any Unix-like system including Linux and macOS. Windows is not supported as a control node, though Windows machines can absolutely be managed as target hosts. Your managed nodes only need SSH access and Python 3.8 or later, which is already installed on most modern Linux distributions.

ComponentRequired VersionNotes
Python3.10+Control node requirement; Python 3.12 recommended
ansible-core2.17+Core engine; latest stable is 2.17.x as of Q1 2026
Ansible package11.xCommunity collections bundle; 11.13.0 was the final 11.x release
SSHOpenSSH 8.0+Required on both control and managed nodes
Operating SystemUbuntu 22.04/24.04 LTSThis tutorial uses Ubuntu; RHEL/CentOS also supported
pip23.0+Python package manager for installing Ansible
Git2.40+For version controlling your Ansible project
VS Code (optional)LatestWith Red Hat Ansible extension for syntax highlighting

You will also need at least two target machines to manage. These can be virtual machines running locally via Vagrant or VirtualBox, cloud instances on AWS, Azure, or Google Cloud, or even containers. For this tutorial, we will use three Ubuntu 22.04 servers: two web servers and one load balancer. If you are using cloud instances, a t3.micro or equivalent is sufficient for learning purposes.

Make sure you have SSH key-based authentication configured between your control node and all managed nodes. Password-based authentication works but is less secure and requires additional configuration with the sshpass package. The industry standard in 2026 is key-based authentication with Ed25519 keys, which we will use throughout this Ansible tutorial.

Step 1: Install Ansible on Your Control Node

The recommended way to install Ansible in 2026 is through pip in a Python virtual environment. This approach isolates your Ansible installation from system packages and lets you manage multiple versions easily. While package managers like apt and dnf can install Ansible, the pip method gives you access to the latest versions and avoids conflicts with system Python packages.

# Create a dedicated project directory and virtual environment
mkdir -p ~/ansible-tutorial && cd ~/ansible-tutorial
python3 -m venv .venv
source .venv/bin/activate

# Upgrade pip and install Ansible
pip install --upgrade pip
pip install ansible==11.1.0

# Verify the installation
ansible --version
# Output: ansible [core 2.17.x]
#   config file = None
#   configured module search path = ['/home/user/.ansible/plugins/modules']
#   ansible python module location = ~/ansible-tutorial/.venv/lib/python3.12/site-packages/ansible
#   executable location = ~/ansible-tutorial/.venv/bin/ansible
#   python version = 3.12.x

# Check installed collections
ansible-galaxy collection list | head -20

The ansible==11.1.0 package installs ansible-core along with hundreds of community collections from Ansible Galaxy. If you need a minimal installation, you can install ansible-core alone, but the full package is recommended for this tutorial because we will use modules from multiple collections including ansible.builtin, ansible.posix, and community.general.

For Red Hat Enterprise Linux or CentOS Stream users, you can alternatively install via dnf: sudo dnf install ansible-core. However, the dnf version may lag behind the pip version by several months. Ansible Automation Platform 2.6, which reached general availability on October 1, 2025 and remains fully supported until October 1, 2026 according to endoflife.date, is a separate commercial product with additional features like Ansible Lightspeed AI assistance and Event-Driven Ansible; Red Hat kept the 2.6 branch current with a 2.6.20260325 patch release on March 25, 2026 that bumped automation controller to 4.7.9 and automation hub to 4.11.7 while rebasing the installer to ansible.platform collection version 2.6.20260306, before moving on to AAP 2.7 (released June 8, 2026), though neither is required for this tutorial, just worth exploring for enterprise environments.

Step 2: Configure Your Ansible Inventory

The inventory file tells Ansible which hosts to manage and how to connect to them. It is the foundation of every Ansible project. You can write inventories in INI format or YAML format. This Ansible tutorial uses YAML because it is more expressive and consistent with the rest of the Ansible ecosystem. Modern best practice in 2026 favors YAML inventories for their support of complex variable structures and better readability.

Create a file called inventory.yml in your project root. This inventory defines three servers organized into two groups: webservers and loadbalancers. Replace the IP addresses with your actual server IPs or hostnames.

# inventory.yml - Ansible inventory for tutorial project
all:
  vars:
    ansible_user: ubuntu
    ansible_ssh_private_key_file: ~/.ssh/id_ed25519
    ansible_python_interpreter: /usr/bin/python3

  children:
    webservers:
      hosts:
        web1:
          ansible_host: 192.168.1.101
          http_port: 8080
        web2:
          ansible_host: 192.168.1.102
          http_port: 8080
      vars:
        app_environment: production
        app_version: "2.1.0"

    loadbalancers:
      hosts:
        lb1:
          ansible_host: 192.168.1.100
      vars:
        nginx_worker_processes: auto
        nginx_worker_connections: 1024

The all group at the top level contains variables shared by every host. The children key defines sub-groups, each with their own hosts and group-specific variables. This hierarchical structure is one of Ansible’s most powerful features because it lets you define variables at exactly the right scope. Host-level variables override group-level variables, which override all-level variables.

Test your inventory by pinging all hosts. This uses the ansible.builtin.ping module, which does not actually send ICMP pings but instead verifies that Ansible can connect to each host via SSH and execute Python.

# Test connectivity to all hosts
ansible -i inventory.yml all -m ping

# Expected output:
# web1 | SUCCESS => {
#     "changed": false,
#     "ping": "pong"
# }
# web2 | SUCCESS => {
#     "changed": false,
#     "ping": "pong"
# }
# lb1 | SUCCESS => {
#     "changed": false,
#     "ping": "pong"
# }

# List all hosts in a specific group
ansible -i inventory.yml webservers --list-hosts

# Gather facts from a single host
ansible -i inventory.yml web1 -m setup | head -50

If the ping fails, check your SSH connectivity manually with ssh -i ~/.ssh/id_ed25519 [email protected]. The most common issues are incorrect SSH key permissions (should be 600), firewall rules blocking port 22, or the wrong username. We cover all of these in the troubleshooting section later in this article.

Step 3: Create Your Ansible Configuration File

The ansible.cfg file controls Ansible’s behavior and sets defaults so you do not have to specify them on every command. Place this file in your project root directory, and Ansible will automatically detect and use it. This is the recommended approach over the global /etc/ansible/ansible.cfg because it keeps your configuration version-controlled alongside your playbooks.

# ansible.cfg - Project-level Ansible configuration
[defaults]
inventory = inventory.yml
remote_user = ubuntu
private_key_file = ~/.ssh/id_ed25519
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
callbacks_enabled = timer, profile_tasks
forks = 10
timeout = 30

[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False

[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o PreferredAuthentications=publickey

Several settings deserve explanation. The forks = 10 setting controls parallelism: Ansible will manage up to 10 hosts simultaneously. For larger environments, increase this to 20 or 50. The pipelining = True setting dramatically improves performance by reducing the number of SSH connections needed per task. Red Hat reports that pipelining can reduce playbook execution time by 30-40% on large inventories.

The stdout_callback = yaml setting formats output in readable YAML instead of the default JSON, making it much easier to read task results during development. The profile_tasks callback adds timing information to every task, which is invaluable for identifying slow tasks in complex playbooks. With these settings in place, you can now run ansible all -m ping without specifying the inventory file each time.

Step 4: Write Your First Playbook

Playbooks are where Ansible’s real power lives. A playbook is a YAML file that describes the desired state of your infrastructure. Unlike shell scripts that describe a sequence of commands, Ansible playbooks are declarative: you describe what you want, and Ansible figures out how to get there. This idempotent approach means you can safely run the same playbook multiple times without causing unintended changes.

Create a file called site.yml that will serve as your main playbook. This initial version sets up the common configuration shared by all servers, including package updates, security hardening, and basic monitoring tools.

# site.yml - Main playbook for infrastructure automation
---
- name: Common configuration for all servers
  hosts: all
  become: true
  gather_facts: true

  vars:
    common_packages:
      - curl
      - wget
      - vim
      - htop
      - unzip
      - net-tools
      - python3-pip
      - ufw
      - fail2ban
    timezone: "America/New_York"
    ntp_servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org

  tasks:
    - name: Update apt cache and upgrade packages
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600
        upgrade: safe
      tags: [packages, update]

    - name: Install common packages
      ansible.builtin.apt:
        name: "{{ common_packages }}"
        state: present
      tags: [packages]

    - name: Set timezone
      community.general.timezone:
        name: "{{ timezone }}"
      tags: [system]

    - name: Configure NTP with systemd-timesyncd
      ansible.builtin.template:
        src: templates/timesyncd.conf.j2
        dest: /etc/systemd/timesyncd.conf
        owner: root
        group: root
        mode: '0644'
      notify: Restart timesyncd
      tags: [system, ntp]

    - name: Enable and start fail2ban
      ansible.builtin.systemd:
        name: fail2ban
        state: started
        enabled: true
      tags: [security]

    - name: Configure UFW - Allow SSH
      community.general.ufw:
        rule: allow
        port: '22'
        proto: tcp
      tags: [security, firewall]

    - name: Enable UFW with default deny incoming
      community.general.ufw:
        state: enabled
        default: deny
        direction: incoming
      tags: [security, firewall]

    - name: Create application user
      ansible.builtin.user:
        name: appuser
        shell: /bin/bash
        create_home: true
        groups: sudo
        append: true
      tags: [users]

    - name: Set up SSH hardening
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "{{ item.regexp }}"
        line: "{{ item.line }}"
        state: present
        backup: true
      loop:
        - { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
        - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
        - { regexp: '^#?X11Forwarding', line: 'X11Forwarding no' }
        - { regexp: '^#?MaxAuthTries', line: 'MaxAuthTries 3' }
      notify: Restart SSH
      tags: [security, ssh]

  handlers:
    - name: Restart timesyncd
      ansible.builtin.systemd:
        name: systemd-timesyncd
        state: restarted

    - name: Restart SSH
      ansible.builtin.systemd:
        name: sshd
        state: restarted

This playbook demonstrates several Ansible fundamentals. The become: true directive enables privilege escalation so tasks run as root. The gather_facts: true setting collects system information that you can reference in templates and conditionals. Tags let you run subsets of tasks with ansible-playbook site.yml --tags security. Handlers only fire when notified by a task that made a change, preventing unnecessary service restarts.

The ansible.builtin.apt module with state: present is idempotent: if a package is already installed, Ansible skips it and reports “ok” instead of “changed.” This is fundamentally different from running apt install in a shell script, which would reinstall or reconfigure packages unnecessarily. Idempotency is the cornerstone of reliable infrastructure automation, and understanding it deeply is essential for writing effective Ansible playbooks.

Step 5: Build Reusable Roles for Web Servers

Roles are Ansible’s mechanism for organizing playbooks into reusable, shareable components. A role encapsulates tasks, templates, files, variables, and handlers into a standardized directory structure. In 2026, with over 20,000 roles available on Ansible Galaxy, roles have become the standard unit of automation code sharing in the Ansible ecosystem. For this tutorial, we will create two roles: one for the Nginx web server and one for deploying our application.

First, create the role directory structure using the ansible-galaxy command:

# Create role scaffolding
ansible-galaxy role init roles/webserver
ansible-galaxy role init roles/app_deploy
ansible-galaxy role init roles/loadbalancer

# Your project structure should now look like:
# ansible-tutorial/
# ├── ansible.cfg
# ├── inventory.yml
# ├── site.yml
# ├── templates/
# │   └── timesyncd.conf.j2
# └── roles/
#     ├── webserver/
#     │   ├── tasks/main.yml
#     │   ├── handlers/main.yml
#     │   ├── templates/
#     │   ├── defaults/main.yml
#     │   └── vars/main.yml
#     ├── app_deploy/
#     │   ├── tasks/main.yml
#     │   ├── handlers/main.yml
#     │   ├── templates/
#     │   └── defaults/main.yml
#     └── loadbalancer/
#         ├── tasks/main.yml
#         ├── handlers/main.yml
#         ├── templates/
#         └── defaults/main.yml

Now populate the webserver role. Edit roles/webserver/defaults/main.yml to define default variables that users of your role can override:

# roles/webserver/defaults/main.yml
---
nginx_version: "latest"
nginx_worker_processes: "auto"
nginx_worker_connections: 1024
nginx_keepalive_timeout: 65
nginx_client_max_body_size: "10m"
nginx_server_name: "_"
nginx_root: "/var/www/html"
nginx_log_dir: "/var/log/nginx"
app_port: 8080
enable_ssl: false
ssl_certificate: ""
ssl_certificate_key: ""

Edit roles/webserver/tasks/main.yml with the Nginx installation and configuration tasks:

# roles/webserver/tasks/main.yml
---
- name: Install Nginx
  ansible.builtin.apt:
    name: "nginx"
    state: present
    update_cache: true
  tags: [nginx, install]

- name: Create web root directory
  ansible.builtin.file:
    path: "{{ nginx_root }}"
    state: directory
    owner: www-data
    group: www-data
    mode: '0755'
  tags: [nginx, config]

- name: Deploy Nginx main configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
    validate: "nginx -t -c %s"
  notify: Reload Nginx
  tags: [nginx, config]

- name: Deploy virtual host configuration
  ansible.builtin.template:
    src: vhost.conf.j2
    dest: /etc/nginx/sites-available/app.conf
    owner: root
    group: root
    mode: '0644'
  notify: Reload Nginx
  tags: [nginx, config]

- name: Enable virtual host
  ansible.builtin.file:
    src: /etc/nginx/sites-available/app.conf
    dest: /etc/nginx/sites-enabled/app.conf
    state: link
  notify: Reload Nginx
  tags: [nginx, config]

- name: Remove default Nginx site
  ansible.builtin.file:
    path: /etc/nginx/sites-enabled/default
    state: absent
  notify: Reload Nginx
  tags: [nginx, config]

- name: Allow HTTP through UFW
  community.general.ufw:
    rule: allow
    port: '80'
    proto: tcp
  tags: [nginx, firewall]

- name: Allow application port through UFW
  community.general.ufw:
    rule: allow
    port: "{{ app_port | string }}"
    proto: tcp
  tags: [nginx, firewall]

- name: Ensure Nginx is started and enabled
  ansible.builtin.systemd:
    name: nginx
    state: started
    enabled: true
  tags: [nginx]

Notice the validate parameter on the Nginx configuration template. This runs nginx -t against the new configuration file before replacing the existing one. If the configuration is invalid, Ansible rolls back the change and reports an error. This safety mechanism prevents you from accidentally breaking a running web server with a syntax error in your template, which is a common pitfall when managing Nginx configurations manually.

Creating Jinja2 Templates for Nginx

Templates are where Ansible’s Jinja2 templating engine shines. Create roles/webserver/templates/nginx.conf.j2 for the main Nginx configuration and roles/webserver/templates/vhost.conf.j2 for the virtual host. The 2025 ansible-core update brought security improvements to the templating engine with an “untrusted by default” approach, plus performance boosts through lazy evaluation for nested operations; Red Hat’s bug-fix advisories kept the 2.17 branch moving throughout 2025, culminating in ansible-core 2.17.14 on September 8, 2025 — per endoflife.date, the final release in the 2.17.x line — underscoring how actively the templating and Jinja2 internals keep getting hardened.

# roles/webserver/templates/vhost.conf.j2
upstream app_backend {
    server 127.0.0.1:{{ app_port }};
}

server {
    listen 80;
    server_name {{ nginx_server_name }};
    root {{ nginx_root }};

    access_log {{ nginx_log_dir }}/app_access.log;
    error_log {{ nginx_log_dir }}/app_error.log;

    client_max_body_size {{ nginx_client_max_body_size }};

    location / {
        proxy_pass http://app_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_connect_timeout 30s;
        proxy_read_timeout 60s;
    }

    location /health {
        access_log off;
        return 200 '{"status": "healthy", "host": "{{ inventory_hostname }}"}';
        add_header Content-Type application/json;
    }

    location /static/ {
        alias {{ nginx_root }}/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

{% if enable_ssl %}
    listen 443 ssl;
    ssl_certificate {{ ssl_certificate }};
    ssl_certificate_key {{ ssl_certificate_key }};
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;
{% endif %}
}

The template uses Jinja2 variables wrapped in double curly braces, which Ansible replaces with actual values from your inventory and role defaults. The {% if enable_ssl %} block conditionally includes SSL configuration, demonstrating how templates can adapt to different environments without duplicating code. The {{ inventory_hostname }} variable is a built-in Ansible fact that automatically contains the current host’s name from the inventory.

Step 6: Create the Application Deployment Role

With the web server role in place, we need a role to deploy the actual application. This role handles pulling application code, installing dependencies, configuring the application, and managing the application process with systemd. This pattern works for any application whether it is a Python Flask app, a Node.js Express server, or a compiled Go binary.

# roles/app_deploy/tasks/main.yml
---
- name: Install application dependencies
  ansible.builtin.apt:
    name:
      - python3-venv
      - python3-dev
      - build-essential
    state: present
  tags: [app, dependencies]

- name: Create application directory
  ansible.builtin.file:
    path: "/opt/{{ app_name }}"
    state: directory
    owner: appuser
    group: appuser
    mode: '0755'
  tags: [app]

- name: Deploy application code
  ansible.builtin.copy:
    src: "app/"
    dest: "/opt/{{ app_name }}/"
    owner: appuser
    group: appuser
    mode: '0644'
  notify: Restart application
  tags: [app, deploy]

- name: Create Python virtual environment
  ansible.builtin.command:
    cmd: "python3 -m venv /opt/{{ app_name }}/venv"
    creates: "/opt/{{ app_name }}/venv/bin/activate"
  become_user: appuser
  tags: [app, dependencies]

- name: Install Python dependencies
  ansible.builtin.pip:
    requirements: "/opt/{{ app_name }}/requirements.txt"
    virtualenv: "/opt/{{ app_name }}/venv"
  become_user: appuser
  notify: Restart application
  tags: [app, dependencies]

- name: Deploy application configuration
  ansible.builtin.template:
    src: app_config.env.j2
    dest: "/opt/{{ app_name }}/.env"
    owner: appuser
    group: appuser
    mode: '0600'
  notify: Restart application
  tags: [app, config]

- name: Deploy systemd service file
  ansible.builtin.template:
    src: app.service.j2
    dest: "/etc/systemd/system/{{ app_name }}.service"
    owner: root
    group: root
    mode: '0644'
  notify:
    - Reload systemd
    - Restart application
  tags: [app, service]

- name: Ensure application is started and enabled
  ansible.builtin.systemd:
    name: "{{ app_name }}"
    state: started
    enabled: true
  tags: [app, service]

- name: Wait for application to be ready
  ansible.builtin.uri:
    url: "http://localhost:{{ app_port }}/health"
    status_code: 200
    timeout: 5
  register: health_check
  retries: 10
  delay: 3
  until: health_check.status == 200
  tags: [app, verify]

The creates parameter on the virtual environment task makes it idempotent: Ansible will skip the task if the venv already exists. The health check at the end uses the ansible.builtin.uri module with retries to wait for the application to start. This is a best practice pattern for deployment automation because it verifies the application is actually serving traffic before the playbook completes.

Create the systemd service template at roles/app_deploy/templates/app.service.j2:

# roles/app_deploy/templates/app.service.j2
[Unit]
Description={{ app_name }} application service
After=network.target

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/{{ app_name }}
Environment=PATH=/opt/{{ app_name }}/venv/bin:/usr/bin
EnvironmentFile=/opt/{{ app_name }}/.env
ExecStart=/opt/{{ app_name }}/venv/bin/python app.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/{{ app_name }}/data
PrivateTmp=true

[Install]
WantedBy=multi-user.target

The systemd service file includes security hardening directives like NoNewPrivileges, ProtectSystem, and PrivateTmp. These restrict the application process to only the permissions it needs, following the principle of least privilege. This is considered essential practice for production deployments in 2026, especially with the 45% year-over-year increase in Ansible adoption driving automation into more security-sensitive environments.

Step 7: Configure the Load Balancer Role

The load balancer distributes incoming traffic across your web servers, providing high availability and scalability. This role configures Nginx as a reverse proxy with health checks and multiple load balancing algorithms. In production environments managing thousands of nodes, companies like Ensono have used Ansible to automate over 40 million tasks annually across 30,000 assets, saving 210,000 hours per year.

# roles/loadbalancer/tasks/main.yml
---
- name: Install Nginx on load balancer
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
  tags: [lb, install]

- name: Deploy load balancer configuration
  ansible.builtin.template:
    src: lb_nginx.conf.j2
    dest: /etc/nginx/sites-available/loadbalancer.conf
    owner: root
    group: root
    mode: '0644'
    validate: "nginx -t -c %s"
  notify: Reload Nginx LB
  tags: [lb, config]

- name: Enable load balancer site
  ansible.builtin.file:
    src: /etc/nginx/sites-available/loadbalancer.conf
    dest: /etc/nginx/sites-enabled/loadbalancer.conf
    state: link
  notify: Reload Nginx LB
  tags: [lb, config]

- name: Remove default site
  ansible.builtin.file:
    path: /etc/nginx/sites-enabled/default
    state: absent
  notify: Reload Nginx LB
  tags: [lb, config]

- name: Allow HTTP through UFW on load balancer
  community.general.ufw:
    rule: allow
    port: '80'
    proto: tcp
  tags: [lb, firewall]

- name: Ensure Nginx is running on load balancer
  ansible.builtin.systemd:
    name: nginx
    state: started
    enabled: true
  tags: [lb]

  handlers:
    - name: Reload Nginx LB
      ansible.builtin.systemd:
        name: nginx
        state: reloaded

The load balancer template dynamically generates the upstream block by iterating over the hosts in the webservers group. Create roles/loadbalancer/templates/lb_nginx.conf.j2:

# roles/loadbalancer/templates/lb_nginx.conf.j2
upstream web_backends {
    least_conn;
{% for host in groups['webservers'] %}
    server {{ hostvars[host]['ansible_host'] }}:{{ hostvars[host]['http_port'] | default(80) }} max_fails=3 fail_timeout=30s;
{% endfor %}
}

server {
    listen 80;
    server_name {{ lb_domain | default('_') }};

    location / {
        proxy_pass http://web_backends;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeout settings
        proxy_connect_timeout 10s;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;

        # Buffer settings
        proxy_buffering on;
        proxy_buffer_size 4k;
        proxy_buffers 8 4k;
    }

    location /lb-health {
        access_log off;
        return 200 '{"status": "healthy", "backends": {{ groups["webservers"] | length }}}';
        add_header Content-Type application/json;
    }
}

The {% for host in groups['webservers'] %} loop is a powerful Jinja2 pattern that dynamically discovers backend servers from the inventory. When you add a new web server to the webservers group in your inventory, the load balancer configuration automatically updates on the next playbook run. This dynamic discovery eliminates the error-prone process of manually editing load balancer configurations and is a key advantage of using Ansible for infrastructure automation.

Step 8: Assemble the Complete Playbook with Role Orchestration

Now update your site.yml to orchestrate all roles together. This is where you define the order of operations and which roles apply to which host groups. The playbook runs top to bottom, with each play executing on its target hosts before moving to the next play.

# site.yml - Complete orchestrated playbook
---
- name: Common configuration for all servers
  hosts: all
  become: true
  gather_facts: true
  tags: [common]

  vars:
    common_packages:
      - curl
      - wget
      - vim
      - htop
      - unzip
      - net-tools
      - python3-pip
      - ufw
      - fail2ban
    timezone: "America/New_York"

  tasks:
    - name: Update and install common packages
      ansible.builtin.apt:
        name: "{{ common_packages }}"
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Set timezone
      community.general.timezone:
        name: "{{ timezone }}"

    - name: Enable UFW with SSH allowed
      community.general.ufw:
        rule: allow
        port: '22'
        proto: tcp

    - name: Enable UFW
      community.general.ufw:
        state: enabled
        default: deny
        direction: incoming

- name: Configure web servers
  hosts: webservers
  become: true
  tags: [webservers]

  vars:
    app_name: "mywebapp"
    app_port: 8080

  roles:
    - role: webserver
    - role: app_deploy

- name: Configure load balancer
  hosts: loadbalancers
  become: true
  tags: [loadbalancer]

  roles:
    - role: loadbalancer

- name: Verify deployment
  hosts: loadbalancers
  become: false
  tags: [verify]

  tasks:
    - name: Test load balancer endpoint
      ansible.builtin.uri:
        url: "http://{{ ansible_host }}/health"
        status_code: 200
      register: lb_health
      retries: 5
      delay: 3
      until: lb_health.status == 200

    - name: Display deployment status
      ansible.builtin.debug:
        msg: "Deployment complete. Load balancer at http://{{ ansible_host }} is healthy."

Run the complete playbook with verbose output to see every task execute:

# Run the full playbook
ansible-playbook site.yml -v

# Run only the webserver tasks
ansible-playbook site.yml --tags webservers

# Run in check mode (dry run) to preview changes
ansible-playbook site.yml --check --diff

# Expected output (abbreviated):
# PLAY [Common configuration for all servers] ****
# TASK [Gathering Facts] ****
# ok: [web1]
# ok: [web2]
# ok: [lb1]
#
# TASK [Update and install common packages] ****
# changed: [web1]
# changed: [web2]
# changed: [lb1]
#
# PLAY [Configure web servers] ****
# TASK [webserver : Install Nginx] ****
# changed: [web1]
# changed: [web2]
#
# ... (more tasks)
#
# PLAY RECAP ****
# lb1   : ok=12  changed=8   unreachable=0  failed=0  skipped=0
# web1  : ok=24  changed=18  unreachable=0  failed=0  skipped=0
# web2  : ok=24  changed=18  unreachable=0  failed=0  skipped=0
#
# Playbook run took 0 days, 0 hours, 2 minutes, 34 seconds

The --check --diff flags are your safety net. Check mode simulates the playbook without making changes, and diff mode shows exactly what would change in each file. Always run this before applying changes to production servers. This practice is especially important as 110 verified companies including Accenture, Wells Fargo, and HSBC rely on Ansible for critical infrastructure automation.

Step 9: Implement Variables and Vault for Secrets Management

Real infrastructure requires secrets: database passwords, API keys, SSL certificates, and other sensitive data. Ansible Vault encrypts these secrets so you can safely commit them to version control. This step adds environment-specific variables and encrypted secrets to your project, following the variable precedence hierarchy that Ansible uses to determine which value wins when the same variable is defined in multiple places.

Create the group_vars directory structure for environment-specific variables:

# Create group_vars directories
mkdir -p group_vars/all group_vars/webservers group_vars/loadbalancers

# group_vars/all/vars.yml - Shared non-secret variables
cat > group_vars/all/vars.yml << 'EOF'
---
app_name: mywebapp
app_environment: production
monitoring_enabled: true
log_retention_days: 30
backup_enabled: true
backup_schedule: "0 2 * * *"
EOF

# Create encrypted vault file for secrets
ansible-vault create group_vars/all/vault.yml
# You'll be prompted for a vault password, then enter:
# ---
# vault_db_password: "SuperSecret123!"
# vault_api_key: "sk-abc123def456"
# vault_ssl_passphrase: "CertPassphrase2026"

# To edit later:
ansible-vault edit group_vars/all/vault.yml

# To view without editing:
ansible-vault view group_vars/all/vault.yml

# Run playbook with vault password prompt
ansible-playbook site.yml --ask-vault-pass

# Or use a password file (don't commit this!)
echo "YourVaultPassword" > .vault_pass
chmod 600 .vault_pass
echo ".vault_pass" >> .gitignore
ansible-playbook site.yml --vault-password-file .vault_pass

The naming convention of prefixing vault variables with vault_ is a best practice that makes it immediately clear which variables are encrypted. In your tasks and templates, reference vault variables by mapping them to regular variable names in vars.yml:

# group_vars/webservers/vars.yml
---
# Map vault variables to application variables
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"

# Web server specific settings
nginx_worker_processes: 2
nginx_worker_connections: 2048
app_port: 8080
app_workers: 4
app_threads: 2

# Health check settings
health_check_path: "/health"
health_check_interval: 30

Ansible’s variable precedence has 22 levels, from least to most specific. The most important to remember are: role defaults (lowest), inventory group vars, inventory host vars, playbook vars, role vars, extra vars via -e (highest). This hierarchy gives you fine-grained control over configuration across different environments. For example, you can override the app_workers variable for a single host by setting it in host_vars/web1/vars.yml without affecting any other server.

Step 10: Add Error Handling and Conditional Logic

Production playbooks need reliable error handling. Ansible provides several mechanisms for managing failures: block/rescue/always for try-catch-finally patterns, ignore_errors for non-critical tasks, and failed_when for custom failure conditions. This step adds these patterns to make your automation resilient.

# roles/app_deploy/tasks/deploy_with_rollback.yml
---
- name: Deploy application with rollback capability
  block:
    - name: Create backup of current deployment
      ansible.builtin.archive:
        path: "/opt/{{ app_name }}"
        dest: "/tmp/{{ app_name }}_backup_{{ ansible_date_time.iso8601_basic_short }}.tar.gz"
        format: gz
      when: ansible_facts['file'] is defined

    - name: Deploy new application code
      ansible.builtin.copy:
        src: "app/"
        dest: "/opt/{{ app_name }}/"
        owner: appuser
        group: appuser
        backup: true

    - name: Install updated dependencies
      ansible.builtin.pip:
        requirements: "/opt/{{ app_name }}/requirements.txt"
        virtualenv: "/opt/{{ app_name }}/venv"
      become_user: appuser

    - name: Restart application
      ansible.builtin.systemd:
        name: "{{ app_name }}"
        state: restarted

    - name: Verify application health after deploy
      ansible.builtin.uri:
        url: "http://localhost:{{ app_port }}/health"
        status_code: 200
      register: post_deploy_health
      retries: 5
      delay: 5
      until: post_deploy_health.status == 200

  rescue:
    - name: Deployment failed - rolling back
      ansible.builtin.debug:
        msg: "Deployment failed! Initiating rollback..."

    - name: Restore backup
      ansible.builtin.unarchive:
        src: "/tmp/{{ app_name }}_backup_{{ ansible_date_time.iso8601_basic_short }}.tar.gz"
        dest: "/"
        remote_src: true

    - name: Restart application with previous version
      ansible.builtin.systemd:
        name: "{{ app_name }}"
        state: restarted

    - name: Fail the play after rollback
      ansible.builtin.fail:
        msg: "Deployment failed and was rolled back. Check logs at /var/log/{{ app_name }}/"

  always:
    - name: Clean up old backups (keep last 5)
      ansible.builtin.shell: |
        ls -t /tmp/{{ app_name }}_backup_*.tar.gz | tail -n +6 | xargs -r rm
      changed_when: false
      ignore_errors: true

The block/rescue/always pattern works exactly like try/catch/finally in programming languages. If any task in the block section fails, execution jumps to the rescue section, which restores the backup and restarts the old version. The always section runs regardless of success or failure, cleaning up old backup files. This rollback pattern is critical for zero-downtime deployments and is used extensively by organizations like BankNXT, which cut deployment times by 40% using Ansible Automation Platform.

Add conditional logic for multi-platform support:

# Conditional tasks based on OS family
- name: Install packages on Debian/Ubuntu
  ansible.builtin.apt:
    name: "{{ debian_packages }}"
    state: present
  when: ansible_os_family == "Debian"

- name: Install packages on RHEL/CentOS
  ansible.builtin.dnf:
    name: "{{ rhel_packages }}"
    state: present
  when: ansible_os_family == "RedHat"

# Conditional based on custom variable
- name: Enable SSL configuration
  ansible.builtin.template:
    src: ssl.conf.j2
    dest: /etc/nginx/conf.d/ssl.conf
  when: enable_ssl | bool
  notify: Reload Nginx

# Conditional with multiple conditions
- name: Apply production-only hardening
  ansible.builtin.include_tasks: hardening.yml
  when:
    - app_environment == "production"
    - ansible_virtualization_type != "docker"

The when clause evaluates Jinja2 expressions. You can use Ansible facts like ansible_os_family, custom variables, and complex boolean logic. The | bool filter ensures string values like “true” or “yes” are properly evaluated as booleans. Always test conditional logic with --check mode before running against production servers.

Common Pitfalls and How to Avoid Them

After helping hundreds of teams adopt Ansible, certain mistakes appear repeatedly. Understanding these pitfalls before you encounter them will save you hours of debugging and prevent potential outages. Here are the most common issues and their solutions.

Pitfall 1: YAML indentation errors. YAML is whitespace-sensitive, and a single wrong indent can change the meaning of your playbook entirely. A task indented one level too deep becomes a property of the previous task instead of a separate task. Always use spaces, never tabs, and configure your editor to show whitespace characters. The yamllint tool catches these errors before Ansible does.

Pitfall 2: Not quoting variable-only values. When a YAML value starts with {{, YAML parsers interpret it as a dictionary. Always wrap variable-only values in quotes: write port: "{{ app_port }}" instead of port: {{ app_port }}. This is Ansible’s most frequently asked question and the source of countless “We could not find a matching template” errors.

Pitfall 3: Running tasks that are not idempotent. Using ansible.builtin.command or ansible.builtin.shell without guards makes your playbook non-idempotent. Always add creates, removes, or when conditions to command tasks so they skip when the desired state already exists. Better yet, use purpose-built modules like ansible.builtin.apt or ansible.builtin.file that are idempotent by design.

Pitfall 4: Forgetting to use become for privileged tasks. Package installation, service management, and file operations in system directories require root privileges. If a task silently fails or reports “permission denied,” check whether you need become: true. Set it at the play level for plays where most tasks need root, and use become: false on individual tasks that should run as the connecting user.

Pitfall 5: Hardcoding values instead of using variables. Every value that might change between environments should be a variable. IP addresses, port numbers, file paths, package versions, and usernames should all come from variables. Use defaults/main.yml in roles for sensible defaults and let inventory or group_vars override them per environment.

Pitfall 6: Not using handlers for service restarts. Placing service restart tasks directly after configuration changes causes unnecessary restarts when the configuration has not actually changed. Handlers only fire when notified, and they only fire once even if notified multiple times during a play. This prevents the web server from restarting five times when five configuration files change.

Pitfall 7: Ignoring the --check --diff safety net. Every playbook should be testable in check mode. Tasks that use command or shell will be skipped in check mode unless you add check_mode: false, which can cause dependent tasks to fail. Design your playbooks to work cleanly in check mode by using registered variables and conditional checks.

Troubleshooting Guide

Even well-written playbooks encounter issues. This troubleshooting guide covers the most common errors you will face during Ansible automation and provides tested solutions for each one. Bookmark this section as a reference for your future Ansible projects.

ErrorCauseSolution
UNREACHABLE! SSH connection errorSSH key not found, wrong user, or firewall blocking port 22Test with ssh -vvv user@host, verify key permissions are 600, check UFW/iptables rules
Permission denied (publickey)SSH key not authorized on target or wrong key pathRun ssh-copy-id -i ~/.ssh/id_ed25519 user@host, verify ~/.ssh/authorized_keys on target
MODULE FAILURE - No module namedPython not installed on managed node or wrong interpreter pathSet ansible_python_interpreter: /usr/bin/python3 in inventory, install python3 on target
The field 'args' has an invalid valueUnquoted Jinja2 variable at the start of a YAML valueWrap the value in quotes: "{{ variable }}" instead of {{ variable }}
AnsibleUndefinedVariableVariable referenced but never defined in any scopeCheck spelling, verify variable is in the correct group_vars/host_vars file, use {{ var | default('fallback') }}
Failed to connect to the host via ssh: Host key verification failedHost key changed or not in known_hostsSet host_key_checking = False in ansible.cfg or accept the key manually first
ERROR! Attempting to decrypt but no vault secrets foundPlaybook references vault-encrypted files but no vault password providedAdd --ask-vault-pass or --vault-password-file .vault_pass to the command
Timeout (12s) waiting for privilege escalation promptsudo requires a password but none was providedAdd --ask-become-pass or configure passwordless sudo for the ansible user
ERROR! conflicting action statementsTwo module names in the same task (often from bad YAML indentation)Check indentation. Each task should have exactly one module. Use yamllint to validate
fatal: [host]: FAILED! => changed=false, msg=No package matching foundPackage name differs between OS versions or apt cache is staleAdd update_cache: true to the apt task, verify the package name with apt search

Debugging technique 1: Increase verbosity. Run playbooks with -v for basic verbose output, -vv for connection info, -vvv for task plugin details, or -vvvv for complete SSH debug output. Start with -vv and increase as needed. The verbose output shows exactly which SSH command Ansible is running, which module arguments it is passing, and what the remote system returns.

Debugging technique 2: Use the debug module. Insert ansible.builtin.debug tasks to print variable values at any point in your playbook. Combine with register to capture and inspect task output: register the output of a task, then use debug: var=result to display it. This is invaluable for understanding why conditional logic is not behaving as expected.

Debugging technique 3: Run on a single host. When debugging, limit execution to one host with --limit web1. This speeds up feedback loops and prevents errors from affecting multiple servers. Combine with --start-at-task "Task Name" to skip directly to the problematic task instead of re-running the entire playbook.

Advanced Tips for Production Ansible

Once you have the basics working, these advanced techniques will take your Ansible automation to production grade. These tips come from real-world experience managing infrastructure at scale and reflect best practices adopted by organizations running Ansible Automation Platform 2.6 in enterprise environments — a release that, per endoflife.date’s May 2026 documentation, still pairs a pinned ansible-core 2.16 with automation controller 4.7.10 under the hood.

Use ansible-lint for code quality. Install ansible-lint and run it against your playbooks before every commit. It catches common antipatterns like using shell when a module exists, missing name attributes on tasks, deprecated module usage, and incorrect FQCN (Fully Qualified Collection Names). In 2026, ansible-lint integrates directly with VS Code through the Red Hat Ansible extension for real-time feedback.

Implement molecule for role testing. Molecule is the standard testing framework for Ansible roles. It spins up a Docker container or cloud instance, runs your role against it, and verifies the result with Testinfra or Ansible assertions. Every role you write should have a molecule test scenario. This catches bugs before they reach production and enables confident refactoring.

Use dynamic inventory for cloud environments. If your infrastructure runs on AWS, Azure, or Google Cloud, stop maintaining static inventory files. Ansible’s cloud inventory plugins automatically discover instances by tags, regions, and other metadata. For AWS, the amazon.aws.aws_ec2 plugin generates inventory from your running EC2 instances. Google adopted Ansible Automation Platform in 2025 specifically for orchestrating automation in complex virtualized environments using dynamic inventory.

# aws_ec2.yml - Dynamic inventory plugin for AWS
plugin: amazon.aws.aws_ec2
regions:
  - us-east-1
  - us-west-2
filters:
  tag:Environment: production
  instance-state-name: running
keyed_groups:
  - key: tags.Role
    prefix: role
  - key: placement.region
    prefix: region
hostnames:
  - private-ip-address
compose:
  ansible_host: private_ip_address
  ansible_user: "'ubuntu'"

Use Ansible Lightspeed for AI-assisted automation. Released with Ansible Automation Platform 2.5 and expanded in 2.6, Ansible Lightspeed uses AI to generate task recommendations and complete playbooks from natural language descriptions. Red Hat kept 2.5 current with a bug-fix advisory on April 9, 2025 that bumped automation controller to 4.6.11 and automation hub to 4.10.3, and with the 2.6 release adding bring-your-own-model support for Red Hat AI, OpenAI, and Azure OpenAI, teams can now choose their preferred AI backend. While not a replacement for understanding Ansible fundamentals, Lightspeed accelerates playbook development significantly.

Implement callback plugins for observability. The profile_tasks callback shows execution time per task, while the json callback outputs structured data perfect for ingestion into monitoring systems. For enterprise environments, the ara callback records playbook runs in a database with a web UI for auditing and troubleshooting historical executions.

Complete Project Structure and File Reference

Here is the complete project structure with every file from this Ansible tutorial. This represents a production-ready project layout that follows Ansible best practices for 2026. You can use this structure as a template for your own projects.

# Complete project tree
ansible-tutorial/
├── ansible.cfg                    # Project configuration
├── inventory.yml                  # Static inventory
├── site.yml                       # Main orchestration playbook
├── requirements.yml               # Galaxy collection dependencies
├── .vault_pass                    # Vault password (gitignored)
├── .gitignore                     # Git ignore rules
│
├── group_vars/
│   ├── all/
│   │   ├── vars.yml               # Shared variables
│   │   └── vault.yml              # Encrypted secrets
│   ├── webservers/
│   │   └── vars.yml               # Web server variables
│   └── loadbalancers/
│       └── vars.yml               # Load balancer variables
│
├── host_vars/
│   └── web1/
│       └── vars.yml               # Host-specific overrides
│
├── roles/
│   ├── webserver/
│   │   ├── defaults/main.yml      # Default variables
│   │   ├── tasks/main.yml         # Nginx tasks
│   │   ├── handlers/main.yml      # Service handlers
│   │   ├── templates/
│   │   │   ├── nginx.conf.j2      # Main nginx config
│   │   │   └── vhost.conf.j2      # Virtual host config
│   │   └── meta/main.yml          # Role metadata
│   │
│   ├── app_deploy/
│   │   ├── defaults/main.yml
│   │   ├── tasks/
│   │   │   ├── main.yml           # Deployment tasks
│   │   │   └── deploy_with_rollback.yml
│   │   ├── handlers/main.yml
│   │   ├── templates/
│   │   │   ├── app.service.j2     # Systemd service
│   │   │   └── app_config.env.j2  # App environment
│   │   ├── files/
│   │   │   └── app/               # Application source code
│   │   └── meta/main.yml
│   │
│   └── loadbalancer/
│       ├── defaults/main.yml
│       ├── tasks/main.yml
│       ├── handlers/main.yml
│       ├── templates/
│       │   └── lb_nginx.conf.j2   # LB config template
│       └── meta/main.yml
│
└── templates/
    └── timesyncd.conf.j2          # NTP configuration

Create a requirements.yml file to pin your collection dependencies:

# requirements.yml - Ansible Galaxy dependencies
---
collections:
  - name: ansible.posix
    version: ">=1.5.0"
  - name: community.general
    version: ">=8.0.0"
  - name: amazon.aws
    version: ">=7.0.0"

# Install with:
# ansible-galaxy collection install -r requirements.yml

This project structure separates concerns clearly: inventory defines the infrastructure, group_vars and host_vars customize behavior per scope, roles encapsulate reusable automation, and the main playbook orchestrates everything. When your team grows, this structure scales naturally. New team members can understand the project by reading the inventory and site.yml, then diving into individual roles as needed.

Ansible vs Other IaC Tools: When to Choose Ansible

Understanding where Ansible fits in the infrastructure automation landscape helps you make the right tool choices. Ansible is not the only option, and in many cases, the best solution combines multiple tools. Here is how Ansible compares to other popular infrastructure-as-code tools in 2026.

FeatureAnsibleTerraformPuppetChef
ArchitectureAgentless (SSH)Agentless (API)Agent-basedAgent-based
LanguageYAML/Jinja2HCLPuppet DSLRuby DSL
Primary UseConfiguration management, deploymentCloud provisioningConfiguration enforcementConfiguration management
State ManagementStatelessState fileCatalog-basedNode objects
Learning CurveLow (YAML)Medium (HCL)High (DSL)High (Ruby)
Cloud SupportModules for all cloudsNative cloud providersCloud pluginsCloud cookbooks
2026 Adoption Trend45% YoY growthDominant for IaCDecliningDeclining
Enterprise PlatformAAP 2.6 with AITerraform Cloud/EnterprisePuppet EnterpriseChef Automate

Ansible excels at configuration management and application deployment. Its agentless architecture means zero software to install on managed nodes, which is crucial in environments where installing agents is restricted by security policies. Terraform, covered in our thorough Terraform AWS tutorial, excels at cloud resource provisioning with its declarative state management. The two tools complement each other perfectly: use Terraform to create your infrastructure, then use Ansible to configure it.

For containerized environments, Ansible works alongside Docker and Kubernetes. Our Docker Compose tutorial covers container orchestration in detail, and our Docker vs Kubernetes comparison helps you decide which container platform suits your needs. Ansible’s community.docker and kubernetes.core collections provide native integration with both platforms, letting you manage containers alongside traditional infrastructure from the same automation framework.

Performance Optimization and Scaling

As your infrastructure grows, playbook execution time becomes a concern. An Ansible playbook managing 10 servers in 2 minutes could take 20 minutes at 100 servers and over an hour at 1,000. These optimization techniques keep execution fast even at scale, drawing from lessons learned by organizations like Turkcell, which achieved zero-touch provisioning with Ansible for 5G workloads handling over 15 terabits per second of traffic.

Enable pipelining. As configured in our ansible.cfg, pipelining reduces SSH operations from 5 per task to 2 by piping the module directly over the SSH connection instead of copying it to a temporary file. This alone can reduce execution time by 30-40%.

Increase forks for parallel execution. The default of 5 forks means Ansible manages only 5 hosts at a time. For larger inventories, increase this to 20, 50, or even 100 depending on your control node’s resources. Each fork uses approximately 20-30MB of RAM, so a control node with 8GB can comfortably run 50 forks.

Use free strategy for independent tasks. The default linear strategy waits for all hosts to finish each task before moving to the next. The free strategy lets fast hosts run ahead, which is useful when hosts have different performance characteristics. Set it with strategy: free at the play level.

Cache facts to avoid repeated gathering. Fact gathering is the slowest part of most playbooks. Enable fact caching with Redis or JSON files so that facts are gathered once and reused across subsequent runs:

# Add to ansible.cfg for JSON fact caching
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 86400

Use async and poll for long-running tasks. Tasks like package upgrades or large file transfers can run asynchronously, freeing the connection for other work. Set async: 300 and poll: 0 to fire-and-forget, then use async_status to check completion later. This is particularly effective for tasks that run identically on many hosts.

Related Coverage

Explore more infrastructure automation and DevOps tutorials on Tech Insider:

Frequently Asked Questions

What is the difference between ansible-core and the Ansible package?

The ansible-core package contains the Ansible engine, command-line tools, and built-in modules. The ansible package includes ansible-core plus hundreds of community-maintained collections from Ansible Galaxy. For most users, installing the full ansible package is recommended because it provides modules for cloud providers, networking equipment, databases, and more without needing to install each collection separately. The community package moved fast after that: the 13.x series wrapped up with 13.8.0 on June 18, 2026 (the same day its support window closed), a 14.0.0 alpha1 preview landed on April 7, 2026, the Ansible 14 series itself debuted on June 2, 2026, and as of August 14, 2026 the latest release is 14.3.1, according to endoflife.date.

Can Ansible manage Windows servers?

Yes. While the Ansible control node must run on Linux or macOS, it can manage Windows hosts over WinRM or SSH. The ansible.windows collection provides modules for Windows-specific tasks like managing IIS, Windows features, registry entries, and Active Directory. Set ansible_connection: winrm in your inventory for Windows hosts. Windows support has improved significantly in 2025-2026 with better PowerShell module integration.

How does Ansible Vault compare to HashiCorp Vault?

Ansible Vault encrypts files at rest within your project, making it suitable for secrets that are committed to version control. HashiCorp Vault is a separate secrets management platform that provides dynamic secret generation, access control, and audit logging. For small to medium projects, Ansible Vault is sufficient. For enterprise environments with complex secret management requirements, use HashiCorp Vault alongside Ansible via the community.hashi_vault collection to dynamically retrieve secrets during playbook execution.

What is the maximum number of hosts Ansible can manage?

There is no hard limit. Ansible has been tested managing tens of thousands of hosts in production. Ensono automates over 40 million tasks annually across 30,000 assets using Ansible Automation Platform. Performance depends on your control node resources, network bandwidth, and playbook complexity. For very large environments (1,000+ hosts), use execution environments with Ansible Automation Platform, increase forks, enable fact caching, and consider splitting your inventory into smaller operational groups.

Should I use Ansible or Terraform?

Use both. Terraform excels at provisioning cloud infrastructure (creating VMs, networks, databases), while Ansible excels at configuring those resources after creation. Terraform manages the lifecycle of infrastructure through state files, while Ansible is stateless and focuses on ensuring the correct configuration exists. Many teams use Terraform to create their cloud infrastructure on AWS, Azure, or GCP, then use Ansible to configure the operating system, install software, and deploy applications.

How do I test Ansible playbooks before running them in production?

Use a multi-layered testing approach. First, run ansible-lint and yamllint for static analysis. Second, use --check --diff mode for dry runs against production. Third, use Molecule with Docker containers for automated role testing. Fourth, maintain a staging environment that mirrors production and run playbooks there first. Fifth, use --limit to roll out changes to a single host before the full fleet. This graduated approach catches issues at every stage before they reach production.

What is Ansible Lightspeed and should I use it?

Ansible Lightspeed is Red Hat’s AI-powered coding assistant for Ansible, introduced with Ansible Automation Platform 2.5 and expanded in 2.6. It generates task recommendations from natural language descriptions and autocompletes playbook code. The 2.6 release added bring-your-own-model support for Red Hat AI, OpenAI, and Azure OpenAI. It is useful for accelerating playbook development, especially for common patterns, but should not replace understanding of Ansible fundamentals. Available through Red Hat Ansible Automation Platform subscriptions.

How do I handle different environments (dev, staging, production) with Ansible?

Use separate inventory files per environment: inventory/dev.yml, inventory/staging.yml, and inventory/production.yml. Shared variables go in group_vars/all/, while environment-specific overrides go in each inventory’s corresponding group_vars. Run playbooks against a specific environment with -i inventory/staging.yml. This pattern keeps your playbooks and roles identical across environments while allowing configuration to differ where needed, such as instance sizes, replica counts, and feature flags.

Marcus Chen

Marcus Chen

Gaming & Consumer Tech Editor

Marcus Chen is a senior editor at Tech Insider, where he leads coverage of the US online gaming market, including sweepstakes and social casinos, alongside consumer technology. He evaluates operators on their published terms, licensing and RNG certifications, stated redemption policies, and corroborating independent reporting, and writes plainly about what the evidence supports. Tech Insider does not run first-party money tests and does not gamble with reader funds. Marcus has reported on the technology and online-gaming industries for more than a decade.

View all articles