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. In 2026, with Red Hat Ansible Automation Platform 2.6 now shipping AI-powered features through Ansible Lightspeed and adoption 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.
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.
| Component | Required Version | Notes |
|---|---|---|
| Python | 3.10+ | Control node requirement; Python 3.12 recommended |
| ansible-core | 2.17+ | Core engine; latest stable is 2.17.x as of Q1 2026 |
| Ansible package | 11.x | Community collections bundle; 11.13.0 was the final 11.x release |
| SSH | OpenSSH 8.0+ | Required on both control and managed nodes |
| Operating System | Ubuntu 22.04/24.04 LTS | This tutorial uses Ubuntu; RHEL/CentOS also supported |
| pip | 23.0+ | Python package manager for installing Ansible |
| Git | 2.40+ | For version controlling your Ansible project |
| VS Code (optional) | Latest | With 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. The Ansible Automation Platform 2.6 released in late 2025 is a separate commercial product with additional features like Ansible Lightspeed AI assistance and Event-Driven Ansible, which are not required for this tutorial but 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.
# 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.
| Error | Cause | Solution |
|---|---|---|
UNREACHABLE! SSH connection error | SSH key not found, wrong user, or firewall blocking port 22 | Test 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 path | Run ssh-copy-id -i ~/.ssh/id_ed25519 user@host, verify ~/.ssh/authorized_keys on target |
MODULE FAILURE - No module named | Python not installed on managed node or wrong interpreter path | Set ansible_python_interpreter: /usr/bin/python3 in inventory, install python3 on target |
The field 'args' has an invalid value | Unquoted Jinja2 variable at the start of a YAML value | Wrap the value in quotes: "{{ variable }}" instead of {{ variable }} |
AnsibleUndefinedVariable | Variable referenced but never defined in any scope | Check 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 failed | Host key changed or not in known_hosts | Set host_key_checking = False in ansible.cfg or accept the key manually first |
ERROR! Attempting to decrypt but no vault secrets found | Playbook references vault-encrypted files but no vault password provided | Add --ask-vault-pass or --vault-password-file .vault_pass to the command |
Timeout (12s) waiting for privilege escalation prompt | sudo requires a password but none was provided | Add --ask-become-pass or configure passwordless sudo for the ansible user |
ERROR! conflicting action statements | Two 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 found | Package name differs between OS versions or apt cache is stale | Add 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.
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. 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.
| Feature | Ansible | Terraform | Puppet | Chef |
|---|---|---|---|---|
| Architecture | Agentless (SSH) | Agentless (API) | Agent-based | Agent-based |
| Language | YAML/Jinja2 | HCL | Puppet DSL | Ruby DSL |
| Primary Use | Configuration management, deployment | Cloud provisioning | Configuration enforcement | Configuration management |
| State Management | Stateless | State file | Catalog-based | Node objects |
| Learning Curve | Low (YAML) | Medium (HCL) | High (DSL) | High (Ruby) |
| Cloud Support | Modules for all clouds | Native cloud providers | Cloud plugins | Cloud cookbooks |
| 2026 Adoption Trend | 45% YoY growth | Dominant for IaC | Declining | Declining |
| Enterprise Platform | AAP 2.6 with AI | Terraform Cloud/Enterprise | Puppet Enterprise | Chef 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:
- How to Deploy AWS Infrastructure with Terraform: Complete Tutorial (2026)
- How to Master Docker Compose: Complete Tutorial with Multi-Container Apps (2026)
- Docker vs Kubernetes 2026: The Leading Container Comparison
- AWS vs Azure vs Google Cloud 2026: The Leading Cloud Platform Comparison
- Cloud Computing in 2026: The Guide
- Cloud Cost Optimization: 7 Strategies That Actually Work
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 final release of the Ansible 11.x series was version 11.13.0 in December 2025.
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 is a Senior Tech Reporter at Tech Insider covering cloud computing, enterprise software, and the business of technology. Before joining TI, he spent five years at ZDNet covering digital transformation across European enterprises and three years at The Register reporting on cloud infrastructure. Marcus is known for his deep dives into cloud cost optimization and multi-cloud strategy. He holds a degree in Computer Science from Imperial College London and speaks regularly at KubeCon and CloudNative events.
View all articles