# Ansible Complete Guide: Playbooks, Roles, Inventory, and Infrastructure Automation Patterns
Ansible is an agentless, push-based automation tool that uses SSH to configure systems, deploy software, and orchestrate complex workflows. Unlike tools that require agents on managed nodes, Ansible needs nothing more than Python and SSH access on target hosts — making it one of the simplest paths from manual operations to fully automated infrastructure.
This guide covers everything from basic inventory management to production-grade automation patterns including rolling updates, canary deployments, and zero-downtime strategies.
---
Ansible vs Terraform vs Chef/Puppet: When to Use What
Choosing the right tool depends on what you are automating and where you are in the infrastructure lifecycle.
| Aspect | Ansible | Terraform | Chef/Puppet |
|---|---|---|---|
| <strong>Primary Use</strong> | Configuration management, app deployment | Infrastructure provisioning | Configuration management |
| <strong>Approach</strong> | Procedural (imperative) | Declarative | Declarative (with procedural elements) |
| <strong>State</strong> | Stateless | Stateful (tfstate) | Stateful (server-based) |
| <strong>Agent</strong> | Agentless (SSH) | Agentless (API calls) | Agent required |
| <strong>Learning Curve</strong> | Low (YAML) | Medium (HCL) | High (Ruby DSL) |
| <strong>Idempotency</strong> | Module-dependent | Built-in | Built-in |
| <strong>Best For</strong> | Day-2 operations | Day-0/Day-1 provisioning | Long-running config drift |
Use Ansible when:
- Configuring existing servers (package installation, service management, file templating)
- Deploying applications with rolling updates
- Running ad-hoc commands across fleet
- Orchestrating multi-step workflows that span multiple systems
Use Terraform when:
- Provisioning cloud infrastructure (VPCs, EC2, RDS, S3)
- Managing infrastructure lifecycle (create, update, destroy)
- You need a state file to track what exists
Combine them: Terraform provisions infrastructure, Ansible configures it. This is the most common production pattern.
---
Inventory Management
The inventory defines which hosts Ansible manages and how to connect to them.
Static Inventory (INI Format)
# inventory/hosts.ini
[webservers]
web01.example.com ansible_host=10.0.1.10
web02.example.com ansible_host=10.0.1.11
web03.example.com ansible_host=10.0.1.12
[dbservers]
db01.example.com ansible_host=10.0.2.10 ansible_user=dbadmin
db02.example.com ansible_host=10.0.2.11 ansible_user=dbadmin
[loadbalancers]
lb01.example.com ansible_host=10.0.0.10
[production:children]
webservers
dbservers
loadbalancers
[production:vars]
ansible_user=deploy
ansible_ssh_private_key_file=~/.ssh/prod_key
ansible_python_interpreter=/usr/bin/python3
YAML Inventory Format
# inventory/hosts.yml
all:
children:
production:
children:
webservers:
hosts:
web01.example.com:
ansible_host: 10.0.1.10
http_port: 8080
web02.example.com:
ansible_host: 10.0.1.11
http_port: 8080
vars:
nginx_version: "1.24"
dbservers:
hosts:
db01.example.com:
ansible_host: 10.0.2.10
postgres_port: 5432
vars:
backup_enabled: true
vars:
ansible_user: deploy
env: production
Dynamic Inventory
For cloud environments, dynamic inventory scripts pull host information from APIs:
# aws_ec2.yml - Dynamic inventory plugin for AWS
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
- us-west-2
keyed_groups:
- key: tags.Environment
prefix: env
- key: tags.Role
prefix: role
- key: instance_type
prefix: type
filters:
instance-state-name: running
"tag:ManagedBy": ansible
compose:
ansible_host: private_ip_address
Run with: ansible-inventory -i aws_ec2.yml --graph to verify group structure.
Host and Group Variables
inventory/
├── hosts.yml
├── group_vars/
│ ├── all.yml
│ ├── webservers.yml
│ └── production.yml
└── host_vars/
├── web01.example.com.yml
└── db01.example.com.yml
# inventory/group_vars/webservers.yml
nginx_worker_processes: auto
nginx_worker_connections: 1024
ssl_certificate_path: /etc/ssl/certs/app.pem
ssl_key_path: /etc/ssl/private/app.key
app_port: 8080
health_check_path: /health
---
Playbook Structure
A playbook is a YAML file containing one or more plays. Each play maps a group of hosts to a set of tasks.
Basic Playbook Anatomy
# playbooks/webserver-setup.yml
---
- name: Configure web servers
hosts: webservers
become: true
gather_facts: true
vars:
app_user: www-data
app_dir: /var/www/app
nginx_port: 80
pre_tasks:
- name: Update apt cache
apt:
update_cache: true
cache_valid_time: 3600
tasks:
- name: Install required packages
apt:
name:
- nginx
- curl
- htop
- unzip
state: present
- name: Create application directory
file:
path: "{{ app_dir }}"
state: directory
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: "0755"
- name: Deploy NGINX configuration
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/app.conf
owner: root
group: root
mode: "0644"
notify: Reload NGINX
- name: Enable site configuration
file:
src: /etc/nginx/sites-available/app.conf
dest: /etc/nginx/sites-enabled/app.conf
state: link
notify: Reload NGINX
- name: Ensure NGINX is running
systemd:
name: nginx
state: started
enabled: true
handlers:
- name: Reload NGINX
systemd:
name: nginx
state: reloaded
post_tasks:
- name: Verify NGINX is responding
uri:
url: "http://localhost:{{ nginx_port }}/health"
status_code: 200
retries: 3
delay: 5
Using Variables and Conditionals
# playbooks/conditional-setup.yml
---
- name: Cross-platform package installation
hosts: all
become: true
tasks:
- name: Install packages on Debian/Ubuntu
apt:
name: "{{ item }}"
state: present
loop:
- nginx
- python3-pip
- git
when: ansible_os_family == "Debian"
- name: Install packages on RHEL/CentOS
yum:
name: "{{ item }}"
state: present
loop:
- nginx
- python3-pip
- git
when: ansible_os_family == "RedHat"
- name: Set timezone
timezone:
name: "{{ server_timezone | default('UTC') }}"
- name: Configure sysctl parameters
sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
state: present
reload: true
loop:
- { key: "net.core.somaxconn", value: "65535" }
- { key: "vm.swappiness", value: "10" }
- { key: "net.ipv4.tcp_tw_reuse", value: "1" }
Registering Variables and Using Results
- name: Application deployment with checks
hosts: webservers
become: true
tasks:
- name: Check if application is already installed
stat:
path: /opt/app/current
register: app_installed
- name: Download application artifact
get_url:
url: "https://artifacts.example.com/app-{{ app_version }}.tar.gz"
dest: /tmp/app-{{ app_version }}.tar.gz
checksum: "sha256:{{ app_checksum }}"
when: not app_installed.stat.exists or force_deploy | default(false)
register: download_result
- name: Extract application
unarchive:
src: /tmp/app-{{ app_version }}.tar.gz
dest: /opt/app/
remote_src: true
when: download_result.changed | default(false)
- name: Run database migrations
command: /opt/app/current/bin/migrate
register: migration_result
changed_when: "'No migrations' not in migration_result.stdout"
failed_when: migration_result.rc != 0
---
Roles: Reusable Automation Units
Roles provide a structured way to organize playbooks into reusable components.
Role Directory Structure
roles/
└── nginx/
├── defaults/
│ └── main.yml # Default variables (lowest priority)
├── vars/
│ └── main.yml # Role variables (higher priority)
├── tasks/
│ └── main.yml # Task list
├── handlers/
│ └── main.yml # Handlers
├── templates/
│ └── nginx.conf.j2 # Jinja2 templates
├── files/
│ └── index.html # Static files
├── meta/
│ └── main.yml # Role metadata and dependencies
└── README.md
Creating a Reusable NGINX Role
# roles/nginx/defaults/main.yml
---
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_keepalive_timeout: 65
nginx_server_name: "_"
nginx_listen_port: 80
nginx_ssl_enabled: false
nginx_ssl_port: 443
nginx_upstream_servers: []
nginx_proxy_pass: "http://app_backend"
nginx_access_log: /var/log/nginx/access.log
nginx_error_log: /var/log/nginx/error.log
# roles/nginx/tasks/main.yml
---
- name: Install NGINX
apt:
name: nginx
state: present
update_cache: true
tags: [nginx, install]
- name: Create SSL directory
file:
path: /etc/nginx/ssl
state: directory
mode: "0700"
when: nginx_ssl_enabled
tags: [nginx, ssl]
- name: Deploy main NGINX configuration
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: Restart NGINX
tags: [nginx, config]
- name: Deploy virtual host configuration
template:
src: vhost.conf.j2
dest: /etc/nginx/sites-available/{{ nginx_server_name }}.conf
owner: root
group: root
mode: "0644"
notify: Reload NGINX
tags: [nginx, config]
- name: Enable virtual host
file:
src: "/etc/nginx/sites-available/{{ nginx_server_name }}.conf"
dest: "/etc/nginx/sites-enabled/{{ nginx_server_name }}.conf"
state: link
notify: Reload NGINX
tags: [nginx, config]
- name: Remove default site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload NGINX
tags: [nginx, config]
- name: Ensure NGINX is started and enabled
systemd:
name: nginx
state: started
enabled: true
tags: [nginx, service]
# roles/nginx/handlers/main.yml
---
- name: Restart NGINX
systemd:
name: nginx
state: restarted
- name: Reload NGINX
systemd:
name: nginx
state: reloaded
# roles/nginx/meta/main.yml
---
galaxy_info:
author: DevOpsKit Team
description: Production-ready NGINX installation and configuration
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Ubuntu
versions:
- focal
- jammy
- name: Debian
versions:
- bullseye
- bookworm
dependencies:
- role: common
vars:
common_packages:
- curl
- openssl
Using Roles in Playbooks
# playbooks/site.yml
---
- name: Configure web tier
hosts: webservers
become: true
roles:
- role: common
- role: nginx
vars:
nginx_ssl_enabled: true
nginx_server_name: app.example.com
nginx_upstream_servers:
- "10.0.1.20:8080"
- "10.0.1.21:8080"
- "10.0.1.22:8080"
- role: monitoring_agent
tags: [monitoring]
---
Jinja2 Templates for Config Files
Jinja2 templates allow you to generate configuration files dynamically based on variables and host facts.
NGINX Configuration Template
# roles/nginx/templates/nginx.conf.j2
user www-data;
worker_processes {{ nginx_worker_processes }};
pid /run/nginx.pid;
events {
worker_connections {{ nginx_worker_connections }};
multi_accept on;
use epoll;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout {{ nginx_keepalive_timeout }};
types_hash_max_size 2048;
client_max_body_size {{ nginx_client_max_body_size | default('16m') }};
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log {{ nginx_access_log }} combined;
error_log {{ nginx_error_log }} warn;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1000;
{% if nginx_upstream_servers | length > 0 %}
upstream app_backend {
least_conn;
{% for server in nginx_upstream_servers %}
server {{ server }};
{% endfor %}
}
{% endif %}
# Include site configurations
include /etc/nginx/sites-enabled/*;
}
Virtual Host Template with SSL
# roles/nginx/templates/vhost.conf.j2
{% if nginx_ssl_enabled %}
server {
listen 80;
server_name {{ nginx_server_name }};
return 301 https://$server_name$request_uri;
}
{% endif %}
server {
{% if nginx_ssl_enabled %}
listen {{ nginx_ssl_port }} ssl http2;
ssl_certificate {{ ssl_certificate_path }};
ssl_certificate_key {{ ssl_key_path }};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
{% else %}
listen {{ nginx_listen_port }};
{% endif %}
server_name {{ nginx_server_name }};
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
{% if nginx_ssl_enabled %}
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
{% endif %}
location / {
proxy_pass {{ nginx_proxy_pass }};
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 "healthy\n";
add_header Content-Type text/plain;
}
{% for location in nginx_extra_locations | default([]) %}
location {{ location.path }} {
{{ location.config }}
}
{% endfor %}
}
Systemd Service Template
# roles/app/templates/app.service.j2
[Unit]
Description={{ app_name }} Application Service
After=network.target
Wants=network-online.target
[Service]
Type=simple
User={{ app_user }}
Group={{ app_group }}
WorkingDirectory={{ app_dir }}/current
ExecStart={{ app_dir }}/current/bin/{{ app_name }} serve
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
# Environment
{% for key, value in app_env_vars.items() %}
Environment="{{ key }}={{ value }}"
{% endfor %}
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths={{ app_dir }}/data {{ app_dir }}/logs
[Install]
WantedBy=multi-user.target
---
Ansible Vault for Secrets
Ansible Vault encrypts sensitive data so you can store it safely in version control.
Creating and Using Vault Files
# Create an encrypted file
ansible-vault create inventory/group_vars/production/vault.yml
# Edit an existing vault file
ansible-vault edit inventory/group_vars/production/vault.yml
# Encrypt an existing file
ansible-vault encrypt secrets.yml
# View encrypted content
ansible-vault view inventory/group_vars/production/vault.yml
# Run playbook with vault password
ansible-playbook site.yml --ask-vault-pass
# Use a password file (for CI/CD)
ansible-playbook site.yml --vault-password-file ~/.vault_pass
Vault Variables Pattern
# inventory/group_vars/production/vars.yml (unencrypted)
db_host: db01.example.com
db_port: 5432
db_name: app_production
db_user: "{{ vault_db_user }}"
db_password: "{{ vault_db_password }}"
ssl_certificate: "{{ vault_ssl_certificate }}"
api_secret_key: "{{ vault_api_secret_key }}"
# inventory/group_vars/production/vault.yml (encrypted)
vault_db_user: app_user
vault_db_password: "s3cur3P@ssw0rd!"
vault_ssl_certificate: |
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAJC1...
-----END CERTIFICATE-----
vault_api_secret_key: "a1b2c3d4e5f6g7h8i9j0"
Multi-Vault Setup for Different Environments
# ansible.cfg
[defaults]
vault_identity_list = dev@~/.vault_pass_dev, staging@~/.vault_pass_staging, prod@~/.vault_pass_prod
# Encrypt with specific identity
ansible-vault encrypt --vault-id prod@~/.vault_pass_prod prod_secrets.yml
---
Modules Deep Dive
Ansible modules are the building blocks of automation. Here are the most critical modules for infrastructure work.
Package Management
# APT (Debian/Ubuntu)
- name: Install multiple packages
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
- name: Remove unused packages
apt:
autoremove: true
autoclean: true
# YUM/DNF (RHEL/CentOS/Fedora)
- name: Install packages with DNF
dnf:
name:
- podman
- buildah
- skopeo
state: latest
- name: Add repository
yum_repository:
name: docker-ce
description: Docker CE Stable
baseurl: https://download.docker.com/linux/centos/$releasever/$basearch/stable
gpgcheck: true
gpgkey: https://download.docker.com/linux/centos/gpg
Service Management with systemd
- name: Manage services
systemd:
name: "{{ item.name }}"
state: "{{ item.state }}"
enabled: "{{ item.enabled | default(true) }}"
daemon_reload: "{{ item.daemon_reload | default(false) }}"
loop:
- { name: nginx, state: started }
- { name: docker, state: started }
- { name: prometheus-node-exporter, state: started }
- { name: cups, state: stopped, enabled: false }
- name: Deploy and start custom service
block:
- name: Copy service file
template:
src: app.service.j2
dest: /etc/systemd/system/{{ app_name }}.service
register: service_file
- name: Reload systemd daemon
systemd:
daemon_reload: true
when: service_file.changed
- name: Start the service
systemd:
name: "{{ app_name }}"
state: started
enabled: true
Docker Module
- name: Docker container management
hosts: docker_hosts
become: true
tasks:
- name: Pull application image
docker_image:
name: "{{ docker_registry }}/{{ app_name }}"
tag: "{{ app_version }}"
source: pull
force_source: true
- name: Create Docker network
docker_network:
name: app_network
driver: bridge
ipam_config:
- subnet: "172.20.0.0/16"
gateway: "172.20.0.1"
- name: Run application container
docker_container:
name: "{{ app_name }}"
image: "{{ docker_registry }}/{{ app_name }}:{{ app_version }}"
state: started
restart_policy: unless-stopped
ports:
- "{{ app_port }}:8080"
networks:
- name: app_network
env:
DATABASE_URL: "{{ db_connection_string }}"
REDIS_URL: "{{ redis_url }}"
LOG_LEVEL: "{{ log_level | default('info') }}"
volumes:
- "/opt/{{ app_name }}/data:/app/data"
- "/opt/{{ app_name }}/logs:/app/logs"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
memory: "{{ container_memory | default('512m') }}"
cpus: "{{ container_cpus | default('1.0') }}"
- name: Prune unused Docker resources
docker_prune:
containers: true
images: true
images_filters:
dangling: true
networks: true
builder_cache: true
Kubernetes Module
- name: Kubernetes resource management
hosts: localhost
gather_facts: false
tasks:
- name: Create namespace
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: Namespace
metadata:
name: "{{ k8s_namespace }}"
labels:
env: "{{ environment }}"
- name: Deploy application
kubernetes.core.k8s:
state: present
definition:
apiVersion: apps/v1
kind: Deployment
metadata:
name: "{{ app_name }}"
namespace: "{{ k8s_namespace }}"
spec:
replicas: "{{ k8s_replicas | default(3) }}"
selector:
matchLabels:
app: "{{ app_name }}"
template:
metadata:
labels:
app: "{{ app_name }}"
version: "{{ app_version }}"
spec:
containers:
- name: "{{ app_name }}"
image: "{{ docker_registry }}/{{ app_name }}:{{ app_version }}"
ports:
- containerPort: 8080
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
- name: Wait for deployment rollout
kubernetes.core.k8s_info:
api_version: apps/v1
kind: Deployment
name: "{{ app_name }}"
namespace: "{{ k8s_namespace }}"
register: deployment_status
until: >-
deployment_status.resources[0].status.readyReplicas | default(0)
== deployment_status.resources[0].spec.replicas
retries: 30
delay: 10
---
Ansible Galaxy and Collections
Ansible Galaxy is the community hub for sharing roles and collections.
Using Collections
# requirements.yml
---
collections:
- name: community.docker
version: ">=3.4.0"
- name: community.general
version: ">=7.0.0"
- name: kubernetes.core
version: ">=2.4.0"
- name: amazon.aws
version: ">=6.0.0"
- name: community.postgresql
version: ">=3.0.0"
roles:
- name: geerlingguy.docker
version: "6.1.0"
- name: geerlingguy.nginx
version: "3.1.0"
- name: cloudalchemy.prometheus
version: "2.28.0"
# Install collections and roles
ansible-galaxy collection install -r requirements.yml
ansible-galaxy role install -r requirements.yml
# Install a single collection
ansible-galaxy collection install community.docker
# List installed collections
ansible-galaxy collection list
Creating Your Own Collection
# Initialize a new collection
ansible-galaxy collection init myorg.infrastructure
# Structure
myorg/infrastructure/
├── docs/
├── galaxy.yml
├── meta/
│ └── runtime.yml
├── plugins/
│ ├── modules/
│ │ └── custom_module.py
│ ├── module_utils/
│ ├── filter/
│ │ └── custom_filters.py
│ └── lookup/
├── roles/
│ ├── nginx/
│ ├── docker/
│ └── monitoring/
├── playbooks/
└── README.md
# galaxy.yml
namespace: myorg
name: infrastructure
version: 1.0.0
readme: README.md
authors:
- DevOpsKit Team
description: Production infrastructure roles and plugins
license: MIT
repository: https://github.com/myorg/ansible-infrastructure
dependencies:
community.docker: ">=3.4.0"
community.general: ">=7.0.0"
---
Production Patterns
Rolling Updates
# playbooks/rolling-deploy.yml
---
- name: Rolling deployment with zero downtime
hosts: webservers
serial: "25%" # Deploy to 25% of hosts at a time
max_fail_percentage: 0 # Stop if any host fails
become: true
vars:
app_version: "{{ deploy_version }}"
health_check_url: "http://localhost:{{ app_port }}/health"
pre_tasks:
- name: Disable host in load balancer
uri:
url: "http://{{ lb_api }}/api/backends/{{ inventory_hostname }}/disable"
method: POST
headers:
Authorization: "Bearer {{ lb_api_token }}"
delegate_to: localhost
- name: Wait for connections to drain
wait_for:
timeout: 30
tasks:
- name: Stop application service
systemd:
name: "{{ app_name }}"
state: stopped
- name: Deploy new version
unarchive:
src: "https://artifacts.example.com/{{ app_name }}-{{ app_version }}.tar.gz"
dest: "/opt/{{ app_name }}/"
remote_src: true
owner: "{{ app_user }}"
group: "{{ app_group }}"
- name: Update symlink to new version
file:
src: "/opt/{{ app_name }}/{{ app_version }}"
dest: "/opt/{{ app_name }}/current"
state: link
- name: Start application service
systemd:
name: "{{ app_name }}"
state: started
- name: Wait for application to be healthy
uri:
url: "{{ health_check_url }}"
status_code: 200
register: health_result
until: health_result.status == 200
retries: 30
delay: 5
post_tasks:
- name: Re-enable host in load balancer
uri:
url: "http://{{ lb_api }}/api/backends/{{ inventory_hostname }}/enable"
method: POST
headers:
Authorization: "Bearer {{ lb_api_token }}"
delegate_to: localhost
- name: Verify traffic is flowing
uri:
url: "{{ health_check_url }}"
status_code: 200
retries: 5
delay: 3
Canary Deployment
# playbooks/canary-deploy.yml
---
- name: Deploy canary instance
hosts: webservers[0] # Deploy to first host only
become: true
vars:
app_version: "{{ deploy_version }}"
canary_weight: 10 # 10% traffic to canary
tasks:
- name: Deploy to canary host
include_role:
name: app_deploy
vars:
version: "{{ app_version }}"
- name: Configure load balancer for canary
uri:
url: "http://{{ lb_api }}/api/canary"
method: POST
body_format: json
body:
host: "{{ inventory_hostname }}"
weight: "{{ canary_weight }}"
headers:
Authorization: "Bearer {{ lb_api_token }}"
delegate_to: localhost
- name: Monitor canary for errors (5 minutes)
uri:
url: "http://{{ monitoring_api }}/api/v1/query"
method: GET
body_format: json
return_content: true
register: canary_metrics
delegate_to: localhost
until: >-
(canary_metrics.json.data.result[0].value[1] | float) < 0.01
retries: 10
delay: 30
vars:
query: 'rate(http_requests_total{host="{{ inventory_hostname }}",status=~"5.."}[1m])'
- name: Full rollout after canary success
hosts: webservers[1:]
serial: "33%"
become: true
tasks:
- name: Deploy to remaining hosts
include_role:
name: app_deploy
vars:
version: "{{ app_version }}"
Blue-Green Deployment
# playbooks/blue-green-deploy.yml
---
- name: Blue-Green deployment
hosts: localhost
gather_facts: false
vars:
active_color: "{{ lookup('file', '/opt/deploy/active_color') | trim }}"
inactive_color: "{{ 'green' if active_color == 'blue' else 'blue' }}"
tasks:
- name: Deploy to inactive environment
include_tasks: deploy-to-group.yml
vars:
target_group: "{{ inactive_color }}_servers"
app_version: "{{ deploy_version }}"
- name: Run smoke tests against inactive environment
uri:
url: "http://{{ inactive_color }}-vip.example.com/health"
status_code: 200
validate_certs: false
retries: 10
delay: 5
- name: Switch traffic to new environment
uri:
url: "http://{{ lb_api }}/api/switch"
method: POST
body_format: json
body:
active: "{{ inactive_color }}"
register: switch_result
- name: Update active color file
copy:
content: "{{ inactive_color }}"
dest: /opt/deploy/active_color
when: switch_result.status == 200
delegate_to: "{{ deploy_controller }}"
---
Error Handling and Idempotency Best Practices
Block/Rescue/Always Pattern
- name: Deployment with rollback capability
hosts: webservers
become: true
tasks:
- name: Deploy with rollback
block:
- name: Backup current version
copy:
src: "/opt/{{ app_name }}/current/"
dest: "/opt/{{ app_name }}/backup/"
remote_src: true
- name: Deploy new version
unarchive:
src: "/tmp/{{ app_name }}-{{ app_version }}.tar.gz"
dest: "/opt/{{ app_name }}/releases/{{ app_version }}/"
remote_src: true
- name: Switch to new version
file:
src: "/opt/{{ app_name }}/releases/{{ app_version }}"
dest: "/opt/{{ app_name }}/current"
state: link
- name: Restart application
systemd:
name: "{{ app_name }}"
state: restarted
- name: Verify deployment health
uri:
url: "http://localhost:{{ app_port }}/health"
status_code: 200
retries: 10
delay: 5
rescue:
- name: Rollback - restore previous version
copy:
src: "/opt/{{ app_name }}/backup/"
dest: "/opt/{{ app_name }}/current/"
remote_src: true
- name: Rollback - restart with old version
systemd:
name: "{{ app_name }}"
state: restarted
- name: Send rollback notification
slack:
token: "{{ slack_token }}"
channel: "#deployments"
msg: "ROLLBACK: {{ app_name }} v{{ app_version }} failed on {{ inventory_hostname }}"
delegate_to: localhost
- name: Fail the play
fail:
msg: "Deployment failed and was rolled back on {{ inventory_hostname }}"
always:
- name: Clean up temporary files
file:
path: "/tmp/{{ app_name }}-{{ app_version }}.tar.gz"
state: absent
- name: Log deployment attempt
lineinfile:
path: /var/log/deployments.log
line: "{{ ansible_date_time.iso8601 }} - {{ app_name }} v{{ app_version }} - {{ 'SUCCESS' if ansible_failed_task is not defined else 'FAILED' }}"
create: true
Idempotency Patterns
- name: Idempotency best practices
hosts: all
become: true
tasks:
# GOOD: Use 'creates' to skip if file exists
- name: Download binary (idempotent)
get_url:
url: "https://releases.example.com/tool-v{{ tool_version }}"
dest: "/usr/local/bin/tool"
mode: "0755"
checksum: "sha256:{{ tool_checksum }}"
# GOOD: Use 'changed_when' for commands
- name: Check current tool version
command: /usr/local/bin/tool --version
register: current_version
changed_when: false
failed_when: false
- name: Upgrade tool if version mismatch
get_url:
url: "https://releases.example.com/tool-v{{ tool_version }}"
dest: "/usr/local/bin/tool"
mode: "0755"
force: true
when: tool_version not in (current_version.stdout | default(''))
# GOOD: Use lineinfile for single-line config changes
- name: Configure SSH MaxAuthTries
lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?MaxAuthTries"
line: "MaxAuthTries 3"
validate: sshd -t -f %s
notify: Restart SSHD
# GOOD: Use blockinfile for multi-line insertions
- name: Add custom iptables rules
blockinfile:
path: /etc/iptables/rules.v4
marker: "# {mark} ANSIBLE MANAGED - App Rules"
block: |
-A INPUT -p tcp --dport {{ app_port }} -j ACCEPT
-A INPUT -p tcp --dport 443 -j ACCEPT
-A INPUT -p tcp --dport 80 -j ACCEPT
notify: Reload iptables
# AVOID: Running commands without idempotency guards
# BAD: command: useradd appuser
# GOOD: Use the user module instead
- name: Create application user
user:
name: "{{ app_user }}"
shell: /bin/bash
home: "/home/{{ app_user }}"
create_home: true
groups: docker
append: true
---
Production Playbook Examples
Complete NGINX Reverse Proxy Setup
# playbooks/nginx-reverse-proxy.yml
---
- name: Production NGINX Reverse Proxy Setup
hosts: loadbalancers
become: true
vars:
nginx_user: www-data
ssl_dir: /etc/nginx/ssl
dhparam_size: 2048
upstream_servers:
- { host: "10.0.1.10", port: 8080, weight: 3 }
- { host: "10.0.1.11", port: 8080, weight: 3 }
- { host: "10.0.1.12", port: 8080, weight: 2 }
tasks:
- name: Install NGINX and dependencies
apt:
name:
- nginx
- certbot
- python3-certbot-nginx
state: present
update_cache: true
- name: Generate DH parameters (may take a while)
openssl_dhparam:
path: "{{ ssl_dir }}/dhparam.pem"
size: "{{ dhparam_size }}"
notify: Reload NGINX
- name: Deploy NGINX main config
template:
src: templates/nginx-main.conf.j2
dest: /etc/nginx/nginx.conf
validate: nginx -t -c %s
notify: Reload NGINX
- name: Deploy upstream configuration
template:
src: templates/upstream.conf.j2
dest: /etc/nginx/conf.d/upstream.conf
notify: Reload NGINX
- name: Deploy rate limiting configuration
copy:
content: |
limit_req_zone $binary_remote_addr zone=api:10m rate=100r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=addr:10m;
dest: /etc/nginx/conf.d/rate-limiting.conf
notify: Reload NGINX
- name: Configure log rotation
copy:
content: |
/var/log/nginx/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 www-data adm
sharedscripts
postrotate
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
endscript
}
dest: /etc/logrotate.d/nginx
- name: Open firewall ports
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- "80"
- "443"
- name: Ensure NGINX is running
systemd:
name: nginx
state: started
enabled: true
handlers:
- name: Reload NGINX
systemd:
name: nginx
state: reloaded
Docker Host Provisioning
# playbooks/docker-host-setup.yml
---
- name: Provision Docker Host
hosts: docker_hosts
become: true
vars:
docker_compose_version: "2.24.0"
docker_users:
- deploy
- monitoring
docker_daemon_config:
storage-driver: overlay2
log-driver: json-file
log-opts:
max-size: "50m"
max-file: "3"
default-address-pools:
- base: "172.17.0.0/12"
size: 24
live-restore: true
userland-proxy: false
experimental: false
metrics-addr: "0.0.0.0:9323"
tasks:
- name: Install prerequisites
apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
- python3-pip
state: present
update_cache: true
- name: Add Docker GPG key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
keyring: /etc/apt/keyrings/docker.gpg
state: present
- name: Add Docker repository
apt_repository:
repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
filename: docker
- name: Install Docker Engine
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
update_cache: true
- name: Configure Docker daemon
copy:
content: "{{ docker_daemon_config | to_nice_json }}"
dest: /etc/docker/daemon.json
owner: root
group: root
mode: "0644"
notify: Restart Docker
- name: Create Docker users and add to docker group
user:
name: "{{ item }}"
groups: docker
append: true
create_home: true
loop: "{{ docker_users }}"
- name: Configure kernel parameters for Docker
sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
state: present
reload: true
sysctl_file: /etc/sysctl.d/99-docker.conf
loop:
- { key: "net.bridge.bridge-nf-call-iptables", value: "1" }
- { key: "net.bridge.bridge-nf-call-ip6tables", value: "1" }
- { key: "net.ipv4.ip_forward", value: "1" }
- { key: "vm.max_map_count", value: "262144" }
- { key: "fs.file-max", value: "2097152" }
- name: Set Docker service resource limits
copy:
content: |
[Service]
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
dest: /etc/systemd/system/docker.service.d/limits.conf
notify:
- Reload Systemd
- Restart Docker
- name: Install Docker Python SDK (for Ansible docker modules)
pip:
name:
- docker
- docker-compose
state: present
- name: Set up Docker log rotation via cron
cron:
name: "Docker system prune"
hour: "3"
minute: "0"
weekday: "0"
job: "docker system prune -af --filter 'until=168h' > /var/log/docker-prune.log 2>&1"
user: root
- name: Ensure Docker is running
systemd:
name: docker
state: started
enabled: true
- name: Verify Docker installation
command: docker info
register: docker_info
changed_when: false
- name: Display Docker info
debug:
msg: "Docker version: {{ docker_info.stdout_lines[0] }}"
handlers:
- name: Reload Systemd
systemd:
daemon_reload: true
- name: Restart Docker
systemd:
name: docker
state: restarted
Kubernetes Node Setup
# playbooks/k8s-node-setup.yml
---
- name: Kubernetes Node Preparation
hosts: k8s_nodes
become: true
vars:
k8s_version: "1.29"
containerd_version: "1.7.11"
pod_network_cidr: "10.244.0.0/16"
cri_socket: "unix:///var/run/containerd/containerd.sock"
tasks:
- name: Disable swap (required for Kubernetes)
block:
- name: Disable swap immediately
command: swapoff -a
changed_when: true
- name: Remove swap from fstab
lineinfile:
path: /etc/fstab
regexp: '\sswap\s'
state: absent
- name: Load required kernel modules
modprobe:
name: "{{ item }}"
state: present
loop:
- overlay
- br_netfilter
- name: Ensure modules load on boot
copy:
content: |
overlay
br_netfilter
dest: /etc/modules-load.d/kubernetes.conf
- name: Configure sysctl for Kubernetes networking
sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
state: present
reload: true
sysctl_file: /etc/sysctl.d/99-kubernetes.conf
loop:
- { key: "net.bridge.bridge-nf-call-iptables", value: "1" }
- { key: "net.bridge.bridge-nf-call-ip6tables", value: "1" }
- { key: "net.ipv4.ip_forward", value: "1" }
- { key: "net.ipv4.conf.all.forwarding", value: "1" }
- name: Install containerd
block:
- name: Install containerd package
apt:
name: containerd.io
state: present
- name: Create containerd config directory
file:
path: /etc/containerd
state: directory
- name: Generate default containerd config
command: containerd config default
register: containerd_config
changed_when: false
- name: Write containerd config
copy:
content: "{{ containerd_config.stdout }}"
dest: /etc/containerd/config.toml
notify: Restart containerd
- name: Enable SystemdCgroup in containerd
lineinfile:
path: /etc/containerd/config.toml
regexp: 'SystemdCgroup = false'
line: ' SystemdCgroup = true'
notify: Restart containerd
- name: Add Kubernetes apt repository
block:
- name: Add Kubernetes GPG key
apt_key:
url: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/deb/Release.key"
keyring: /etc/apt/keyrings/kubernetes-apt-keyring.gpg
- name: Add Kubernetes repository
apt_repository:
repo: "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/deb/ /"
state: present
filename: kubernetes
- name: Install Kubernetes packages
apt:
name:
- kubelet
- kubeadm
- kubectl
state: present
update_cache: true
- name: Hold Kubernetes packages at current version
dpkg_selections:
name: "{{ item }}"
selection: hold
loop:
- kubelet
- kubeadm
- kubectl
- name: Configure kubelet extra args
copy:
content: |
KUBELET_EXTRA_ARGS=--node-ip={{ ansible_default_ipv4.address }} --cgroup-driver=systemd
dest: /etc/default/kubelet
notify: Restart kubelet
- name: Ensure kubelet is enabled
systemd:
name: kubelet
enabled: true
state: started
handlers:
- name: Restart containerd
systemd:
name: containerd
state: restarted
daemon_reload: true
- name: Restart kubelet
systemd:
name: kubelet
state: restarted
daemon_reload: true
# Control plane initialization (run only on first master)
- name: Initialize Kubernetes Control Plane
hosts: k8s_masters[0]
become: true
tasks:
- name: Check if cluster is already initialized
stat:
path: /etc/kubernetes/admin.conf
register: k8s_initialized
- name: Initialize cluster with kubeadm
command: >
kubeadm init
--pod-network-cidr={{ pod_network_cidr }}
--apiserver-advertise-address={{ ansible_default_ipv4.address }}
--cri-socket={{ cri_socket }}
register: kubeadm_init
when: not k8s_initialized.stat.exists
- name: Create .kube directory for admin user
file:
path: "/home/{{ ansible_user }}/.kube"
state: directory
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0755"
- name: Copy admin kubeconfig
copy:
src: /etc/kubernetes/admin.conf
dest: "/home/{{ ansible_user }}/.kube/config"
remote_src: true
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
mode: "0600"
- name: Install Flannel CNI plugin
become_user: "{{ ansible_user }}"
command: kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
when: not k8s_initialized.stat.exists
- name: Generate join command
command: kubeadm token create --print-join-command
register: join_command
changed_when: false
- name: Store join command
set_fact:
k8s_join_command: "{{ join_command.stdout }}"
# Join worker nodes to cluster
- name: Join Worker Nodes
hosts: k8s_workers
become: true
tasks:
- name: Check if node is already joined
stat:
path: /etc/kubernetes/kubelet.conf
register: node_joined
- name: Join node to cluster
command: "{{ hostvars[groups['k8s_masters'][0]]['k8s_join_command'] }}"
when: not node_joined.stat.exists
---
Ansible Configuration Best Practices
Project Structure
ansible-project/
├── ansible.cfg
├── inventory/
│ ├── production/
│ │ ├── hosts.yml
│ │ ├── group_vars/
│ │ └── host_vars/
│ └── staging/
│ ├── hosts.yml
│ ├── group_vars/
│ └── host_vars/
├── playbooks/
│ ├── site.yml
│ ├── webservers.yml
│ ├── dbservers.yml
│ └── deploy.yml
├── roles/
│ ├── common/
│ ├── nginx/
│ ├── docker/
│ └── monitoring/
├── collections/
│ └── requirements.yml
├── filter_plugins/
├── callback_plugins/
├── templates/
├── files/
├── Makefile
└── README.md
Recommended ansible.cfg
# ansible.cfg
[defaults]
inventory = inventory/production
roles_path = roles:~/.ansible/roles
collections_path = collections
remote_user = deploy
private_key_file = ~/.ssh/deploy_key
host_key_checking = false
retry_files_enabled = false
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 3600
stdout_callback = yaml
callbacks_enabled = timer, profile_tasks
forks = 20
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 StrictHostKeyChecking=no
control_path_dir = ~/.ansible/cp
Makefile for Common Operations
# Makefile
.PHONY: lint deploy test
INVENTORY ?= production
PLAYBOOK ?= site.yml
lint:
ansible-lint playbooks/ roles/
yamllint playbooks/ roles/ inventory/
syntax-check:
ansible-playbook playbooks/$(PLAYBOOK) -i inventory/$(INVENTORY) --syntax-check
dry-run:
ansible-playbook playbooks/$(PLAYBOOK) -i inventory/$(INVENTORY) --check --diff
deploy:
ansible-playbook playbooks/$(PLAYBOOK) -i inventory/$(INVENTORY) --diff
deploy-tag:
ansible-playbook playbooks/$(PLAYBOOK) -i inventory/$(INVENTORY) --tags "$(TAGS)"
deploy-limit:
ansible-playbook playbooks/$(PLAYBOOK) -i inventory/$(INVENTORY) --limit "$(LIMIT)"
vault-edit:
ansible-vault edit inventory/$(INVENTORY)/group_vars/all/vault.yml
test:
molecule test -s default
---
Summary
Ansible excels at configuration management, application deployment, and multi-tier orchestration where you need procedural control over execution order. The key principles for production success:
The combination of simplicity (YAML syntax, SSH transport, no agents) and power (2,000+ modules, Galaxy ecosystem, Jinja2 templating) makes Ansible the go-to tool for teams managing dozens to thousands of servers across hybrid environments.
---
Frequently Asked Questions
What is Ansible and how is it different from Terraform?
Ansible is an agentless configuration management and automation tool that uses SSH to manage servers, while Terraform is an infrastructure provisioning tool. Ansible excels at configuring existing servers and deploying applications, whereas Terraform creates and manages cloud resources. Many teams use both together — Terraform to provision infrastructure and Ansible to configure it.
How do I run an Ansible playbook on specific hosts only?
Use the --limit flag followed by the host or group name, like ansible-playbook site.yml --limit webservers. You can also use patterns like --limit 'webservers:&staging' to target the intersection of groups. For a single host, use --limit host1.example.com.
Why is Ansible saying "unreachable" for my host?
The "unreachable" error typically means Ansible cannot establish an SSH connection to the target host. Check that SSH is running on the target, your SSH key is correctly configured, the inventory hostname or IP is correct, and any firewalls or security groups allow SSH traffic on port 22. Run with -vvv for detailed connection debugging.
What is the difference between Ansible roles and playbooks?
Playbooks are YAML files that define a set of tasks to execute on hosts, while roles are a structured way to organize playbooks into reusable components. Roles separate tasks, handlers, variables, templates, and files into a standard directory structure. Use roles when you need to reuse automation across multiple playbooks or share it with other teams.
How do I handle secrets in Ansible?
Use Ansible Vault to encrypt sensitive variables and files with ansible-vault encrypt secrets.yml. You can encrypt entire files or individual variables inline using !vault tags. Reference the vault password at runtime with --ask-vault-pass or --vault-password-file for automation.