Ansible Automation
Ansible is an agentless automation tool for configuration management, application deployment, and task automation using simple YAML playbooks. This guide covers production patterns for server provisioning, configuration management, and integration with Terraform.
Ansible Architecture
Ansible operates on a push-based, agentless model. The control node connects to managed nodes via SSH (Linux) or WinRM (Windows):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Control Node โ
โ โโโโโโโโโโโโโ โโโโโโโโโโโโโ โโโโโโโโโโโโโ โ
โ โ Inventory โ โ Playbooks โ โ Roles โ โ
โ โ files โ โ (YAML) โ โ (reusable)โ โ
โ โโโโโโโฌโโโโโโ โโโโโโโฌโโโโโโ โโโโโโโฌโโโโโโ โ
โ โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโ โ
โ โผ โ
โ ansible-playbook โ
โโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SSH / WinRM
โโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโ
โผ โผ โผ
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โ Web Server 1โ โ Web Server 2โ โ DB Server โ
โ (Managed) โ โ (Managed) โ โ (Managed) โ
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
Control Node Requirements
- Python 3.9+ installed
- Ansible installed (
pip install ansibleor system package) - SSH key access to all managed nodes
- Inventory files defining target hosts
Managed Node Requirements
- Python 3.x installed (for Ansible modules)
- SSH server running (Linux) or WinRM configured (Windows)
- Service account or SSH key for authentication
- No agent installation required
Inventory Files
Ansible inventories define the target hosts and their variables. Both INI and YAML formats are supported:
INI Format
# inventory/production.ini
[webservers]
web-prod-[01:05].example.com ansible_user=deployer ansible_python_interpreter=/usr/bin/python3
[dbservers]
db-prod-01.example.com ansible_user=deployer mysql_role=master
db-prod-02.example.com ansible_user=deployer mysql_role=replica
[cacheservers]
redis-prod-01.example.com
redis-prod-02.example.com
[monitoring]
prom-prod-01.example.com
grafana-prod-01.example.com
# Groups of groups
[servers:children]
webservers
dbservers
cacheservers
# Group variables
[servers:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/production.pem
ansible_become=true
ansible_become_method=sudo
env=production
datacenter=us-east-1
YAML Format (Recommended)
# inventory/production.yml
all:
children:
webservers:
hosts:
web-prod-01:
ansible_host: 10.0.1.10
nginx_worker_processes: 4
web-prod-02:
ansible_host: 10.0.1.11
nginx_worker_processes: 4
web-prod-03:
ansible_host: 10.0.1.12
nginx_worker_processes: 2
vars:
app_role: web
dbservers:
hosts:
db-prod-01:
ansible_host: 10.0.2.10
mysql_role: master
db-prod-02:
ansible_host: 10.0.2.11
mysql_role: replica
vars:
app_role: database
cacheservers:
hosts:
redis-prod-01:
ansible_host: 10.0.3.10
redis-prod-02:
ansible_host: 10.0.3.11
vars:
ansible_user: ubuntu
ansible_python_interpreter: /usr/bin/python3
ansible_ssh_private_key_file: "~/.ssh/production.pem"
ansible_become: true
env: production
datacenter: us-east-1
Dynamic Inventory (AWS EC2)
# ansible.cfg
[inventory]
enable_plugins = amazon.aws.aws_ec2, yaml, ini
# inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
- us-west-2
filters:
instance-state-name: running
tag:Environment:
- production
keyed_groups:
- key: tags.Role
prefix: role
- key: tags.Environment
prefix: env
compose:
ansible_host: public_ip_address | default(private_ip_address)
# Usage:
# ansible-inventory -i inventory/aws_ec2.yml --graph
# ansible-playbook -i inventory/aws_ec2.yml site.yml
Playbook Structure
A playbook is a YAML file containing one or more plays. Each play maps hosts to tasks:
---
# site.yml โ Main playbook
- name: Configure all production servers
hosts: all
become: yes
gather_facts: yes
pre_tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
roles:
- common
- monitoring-agent
- name: Configure web servers
hosts: webservers
become: yes
roles:
- nginx
- php
- deploy-app
post_tasks:
- name: Verify web application is responding
uri:
url: "http://localhost/health"
status_code: 200
register: health_check
retries: 5
delay: 5
until: health_check.status == 200
Complete Working Playbook: Install and Configure Nginx with SSL
---
# playbooks/nginx-ssl.yml
- name: Install and configure Nginx with SSL
hosts: webservers
become: yes
gather_facts: yes
vars:
nginx_version: "1.24"
certbot_email: "admin@example.com"
domains:
- example.com
- www.example.com
nginx_worker_processes: "auto"
nginx_worker_connections: 4096
nginx_client_max_body_size: "100M"
vars_files:
- "vars/{{ env | default('dev') }}.yml"
tasks:
# โโ Prerequisites โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- name: Install required packages
apt:
name:
- nginx
- certbot
- python3-certbot-nginx
- openssl
- fail2ban
state: present
update_cache: yes
tags: [packages]
# โโ Firewall Configuration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- name: Allow HTTP traffic
ufw:
rule: allow
port: '80'
proto: tcp
- name: Allow HTTPS traffic
ufw:
rule: allow
port: '443'
proto: tcp
# โโ SSL Certificate โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- name: Create SSL directory
file:
path: "/etc/ssl/{{ item }}"
state: directory
mode: '0755'
loop: "{{ domains }}"
- name: Generate self-signed certificate (dev only)
openssl_certificate:
path: "/etc/ssl/{{ item }}/cert.pem"
privatekey_path: "/etc/ssl/{{ item }}/key.pem"
csr_path: "/etc/ssl/{{ item }}/cert.csr"
provider: selfsigned
selfsigned_not_after: "+365d"
loop: "{{ domains }}"
when: env | default('dev') == 'dev'
- name: Obtain Let's Encrypt certificate (production)
command: >
certbot --nginx
--non-interactive
--agree-tos
--email {{ certbot_email }}
-d {{ domains | join(',') }}
--redirect
when: env | default('dev') == 'production'
notify: reload nginx
# โโ Nginx Configuration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- name: Deploy nginx.conf
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: reload nginx
- name: Deploy site configuration
template:
src: templates/site.conf.j2
dest: "/etc/nginx/sites-available/{{ domains[0] }}"
owner: root
group: root
mode: '0644'
notify: reload nginx
- name: Enable site
file:
src: "/etc/nginx/sites-available/{{ domains[0] }}"
dest: "/etc/nginx/sites-enabled/{{ domains[0] }}"
state: link
notify: reload nginx
- name: Remove default site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: reload nginx
# โโ Security Headers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- name: Configure security headers
template:
src: templates/security-headers.conf.j2
dest: /etc/nginx/conf.d/security-headers.conf
owner: root
group: root
mode: '0644'
notify: reload nginx
# โโ Verify Configuration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- name: Validate nginx configuration
command: nginx -t
changed_when: false
- name: Ensure nginx is running and enabled
service:
name: nginx
state: started
enabled: yes
# โโ Health Check โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- name: Verify nginx is responding
uri:
url: "https://{{ domains[0] }}/health"
status_code: 200
validate_certs: "{{ env | default('dev') != 'dev' }}"
register: result
retries: 5
delay: 3
until: result.status == 200
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
- name: restart nginx
service:
name: nginx
state: restarted
Nginx Templates
# templates/nginx.conf.j2
user www-data;
worker_processes {{ nginx_worker_processes }};
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections {{ nginx_worker_connections }};
multi_accept on;
use epoll;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size {{ nginx_client_max_body_size }};
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript text/javascript;
# Security
server_tokens off;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
Roles: Directory Structure and Usage
Roles are the primary mechanism for organizing and reusing Ansible code:
roles/
โโโ nginx/
โโโ defaults/ # Default variables (lowest precedence)
โ โโโ main.yml
โโโ vars/ # Variables (higher precedence)
โ โโโ main.yml
โโโ tasks/ # Main task list
โ โโโ main.yml # Entry point
โ โโโ install.yml
โ โโโ configure.yml
โ โโโ ssl.yml
โโโ handlers/ # Event handlers
โ โโโ main.yml
โโโ templates/ # Jinja2 templates
โ โโโ nginx.conf.j2
โ โโโ site.conf.j2
โโโ files/ # Static files to copy
โ โโโ favicon.ico
โโโ meta/ # Role metadata and dependencies
โ โโโ main.yml
โโโ molecule/ # Testing scenarios
โ โโโ default/
โ โโโ converge.yml
โ โโโ verify.yml
โ โโโ molecule.yml
โโโ tests/ # Legacy tests
โ โโโ inventory
โ โโโ test.yml
โโโ README.md
Using Roles
# requirements.yml
oles:
- name: geerlingguy.nginx
version: 3.1.4
- name: geerlingguy.mysql
version: 4.3.0
- src: https://github.com/my-org/ansible-role-custom-app.git
version: v1.2.0
name: custom-app
# Install roles
ansible-galaxy install -r requirements.yml
# Playbook with roles
- name: Configure application stack
hosts: app_servers
become: yes
roles:
- role: geerlingguy.nginx
vars:
nginx_worker_processes: 4
- role: geerlingguy.mysql
vars:
mysql_databases:
- name: myapp
mysql_users:
- name: myapp
password: "{{ vault_mysql_password }}"
priv: "myapp.*:ALL"
- role: custom-app
Vault for Encrypting Sensitive Data
Ansible Vault encrypts sensitive data at rest within your repository:
# Create an encrypted file
ansible-vault create group_vars/all/vault.yml
# Edit an encrypted file
ansible-vault edit group_vars/all/vault.yml
# Encrypt an existing file
ansible-vault encrypt secrets.yml
# Decrypt a file for viewing
ansible-vault decrypt secrets.yml
# Change vault password
ansible-vault rekey secrets.yml
# Run playbook with vault password file
ansible-playbook -i inventory/production.ini site.yml --vault-password-file .vault_pass
# Or prompt for password
ansible-playbook -i inventory/production.ini site.yml --ask-vault-pass
# Example vault.yml content (unencrypted):
vault_mysql_root_password: "SuperSecretPassword123!"
vault_mysql_app_password: "AppPassword456!"
vault_api_secret_key: "sk-live-abcdefghijklmnopqrstuvwxyz"
# Reference in playbook:
- name: Configure MySQL root password
mysql_user:
name: root
password: "{{ vault_mysql_root_password }}"
login_unix_socket: /var/run/mysqld/mysqld.sock
Ansible + Terraform Integration Pattern
The Terraform + Ansible integration pattern uses Terraform for infrastructure provisioning and Ansible for configuration management:
# Step 1: Terraform provisions infrastructure and generates inventory
# terraform/main.tf
resource "aws_instance" "web" {
count = 3
ami = data.aws_ami.ubuntu.id
instance_type = "t3.medium"
subnet_id = aws_subnet.public.id
key_name = var.ssh_key_name
tags = {
Name = "web-${count.index + 1}"
Role = "webserver"
AnsibleManaged = "true"
}
}
# terraform/outputs.tf
output "web_server_ips" {
value = aws_instance.web[*].public_ip
}
# terraform/templates/inventory.yml.tpl (used with templatefile)
webservers:
hosts:
%{ for idx, ip in web_ips ~}
web-${idx + 1}:
ansible_host: ${ip}
%{ endfor ~}
# Step 2: Local exec provisioner generates inventory
resource "local_file" "ansible_inventory" {
content = templatefile("${path.module}/templates/inventory.yml.tpl", {
web_ips = aws_instance.web[*].public_ip
})
filename = "${path.module}/../ansible/inventory/terraform.yml"
depends_on = [aws_instance.web]
}
# Step 3: Trigger Ansible after Terraform apply
resource "null_resource" "ansible" {
triggers = {
always_run = timestamp()
}
provisioner "local-exec" {
command = <<EOF
cd ${path.module}/../ansible && \
ansible-playbook \
-i inventory/terraform.yml \
-i inventory/production.yml \
--private-key ${var.ssh_private_key_path} \
--vault-password-file .vault_pass \
site.yml
EOF
}
depends_on = [local_file.ansible_inventory]
}
local-exec to trigger Ansible ties your configuration management to your provisioning tool. For production at scale, prefer decoupled execution: Terraform outputs are consumed by a separate CI/CD pipeline that runs Ansible. This separation allows independent rollbacks and avoids Terraform destroy triggering configuration changes.
Ansible Tower / AWX Overview
| Feature | CLI Ansible | AWX (Open Source) | Ansible Tower |
|---|---|---|---|
| Web UI | No | Yes | Yes |
| Role-based access control | No | Yes | Yes |
| Job scheduling | cron | Built-in | Built-in |
| Workflow orchestration | Manual | Visual workflow editor | Visual workflow editor |
| Survey forms | No | Yes | Yes |
| Credential management | Files | Encrypted in database | Encrypted in database |
| Support | Community | Community | Red Hat support |
| Cost | Free | Free | License per managed node |
Idempotency Best Practices
Idempotency means running a playbook multiple times produces the same result. Ansible modules are inherently idempotent, but tasks must be written correctly:
# GOOD: Idempotent โ only changes if file differs
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
# BAD: Not idempotent โ always reports changed
shell: cp /tmp/nginx.conf /etc/nginx/nginx.conf
# GOOD: Idempotent package installation
apt:
name: nginx
state: present
# GOOD: Idempotent service management
service:
name: nginx
state: started
enabled: yes
# GOOD: Idempotent user creation
user:
name: deploy
uid: 1001
groups: [www-data, docker]
append: yes
shell: /bin/bash
create_home: yes
# BAD: Not idempotent โ always appends
shell: echo "export PATH=/opt/app/bin:$PATH" >> /home/deploy/.bashrc
# GOOD: Idempotent lineinfile
lineinfile:
path: /home/deploy/.bashrc
line: 'export PATH=/opt/app/bin:$PATH'
state: present
Conditionals, Loops, and Handlers
# โโ Conditionals โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- name: Install Apache (Red Hat)
yum:
name: httpd
state: present
when: ansible_os_family == "RedHat"
- name: Install Apache (Debian)
apt:
name: apache2
state: present
when: ansible_os_family == "Debian"
- name: Restart service only if configuration changed
service:
name: nginx
state: restarted
when: nginx_config.changed | default(false)
# Multiple conditions
- name: Install monitoring agent (production Linux only)
apt:
name: datadog-agent
state: present
when:
- env == "production"
- ansible_os_family == "Debian"
- ansible_virtualization_type != "docker"
# โโ Loops โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# Standard loop
- name: Create application directories
file:
path: "/opt/myapp/{{ item }}"
state: directory
mode: '0755'
loop:
- config
- logs
- tmp
- uploads
# Loop with dicts
- name: Create users
user:
name: "{{ item.name }}"
uid: "{{ item.uid }}"
groups: "{{ item.groups }}"
shell: "{{ item.shell | default('/bin/bash') }}"
loop:
- { name: 'deploy', uid: 1001, groups: 'www-data' }
- { name: 'backup', uid: 1002, groups: 'backup' }
# Loop over a variable
- name: Install packages
apt:
name: "{{ item }}"
state: present
loop: "{{ base_packages }}"
# โโ Handlers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
# handlers/main.yml
- name: restart nginx
service:
name: nginx
state: restarted
- name: reload nginx
service:
name: nginx
state: reloaded
- name: restart mysql
service:
name: mysql
state: restarted
# Notify handlers from tasks
- name: Deploy application configuration
template:
src: app.config.j2
dest: /opt/myapp/config/app.yml
notify:
- restart myapp
- name: Deploy systemd service file
template:
src: myapp.service.j2
dest: /etc/systemd/system/myapp.service
notify:
- reload systemd
- restart myapp
Best Practices Summary
| Practice | Implementation |
|---|---|
| Use roles for reusable components | Extract common patterns into roles with galaxy structure |
| Encrypt all secrets with Vault | No plaintext passwords in repositories |
| Use YAML inventory for complex environments | Better structure and variable scoping than INI |
| Tag all tasks | Enable targeted execution: ansible-playbook --tags nginx |
| Use handlers for service restarts | Avoid unnecessary restarts; only restart on change |
| Use check mode for dry runs | ansible-playbook --check --diff before applying |
| Limit playbook output | Use no_log: true for tasks handling secrets |
| Test with Molecule | Use Docker/VM-based testing for role validation |
| Use block/rescue/always | Implement proper error handling in playbooks |
| Pin role versions | Use requirements.yml with explicit version tags |