Skip to main content
Infrastructure·29 min read

Ansible Playbooks, Roles & Inventory — Automate What You're Still Doing Manually Over SSH

Master Ansible for infrastructure automation — inventory management, playbooks, roles, Ansible Galaxy, Jinja2 templates, vaults, and production-ready automation patterns.

DT

DevOps Engineer & Technical Writer

# 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.

ANSIBLE PUSH-BASED ARCHITECTURE Control Node Playbooks (YAML) Inventory Roles and Modules No agents required SSH SSH SSH MANAGED NODES Web Servers web01 - web02 - web03 DB Servers db01 - db02 Load Balancers lb01 EXECUTION RESULTS ok: 12 changed: 5 failed: 0 skipped: 2

AspectAnsibleTerraformChef/Puppet
<strong>Primary Use</strong>Configuration management, app deploymentInfrastructure provisioningConfiguration management
<strong>Approach</strong>Procedural (imperative)DeclarativeDeclarative (with procedural elements)
<strong>State</strong>StatelessStateful (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-dependentBuilt-inBuilt-in
<strong>Best For</strong>Day-2 operationsDay-0/Day-1 provisioningLong-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

# 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:

  • Keep playbooks idempotent — running them multiple times should produce the same result
  • Use roles for reusability — extract common patterns into roles with sensible defaults
  • Encrypt secrets with Vault — never commit plaintext credentials
  • Test with --check and --diff — verify changes before applying
  • Use serial and max_fail_percentage — protect against fleet-wide failures
  • Implement health checks — validate deployments before marking them complete
  • Structure your project — separate inventory, playbooks, roles, and variables
  • Use dynamic inventory — pull host information from cloud APIs for autoscaling environments
  • Leverage collections — use community-maintained modules for Docker, Kubernetes, and cloud providers
  • Combine with Terraform — let Terraform handle provisioning, Ansible handle configuration
  • 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.