Ansible automates server configuration, application deployment, and orchestration using YAML playbooks — no agent required, just SSH. Idempotency means running a playbook twice leaves the system in the same state. Roles bundle tasks, handlers, templates, and defaults into reusable units. Ansible Vault encrypts secrets inline. Molecule tests roles against Docker containers before deploying to real servers. Claude Code generates playbooks, role scaffolding, Jinja2 templates, and the testing infrastructure for production Ansible automation.
CLAUDE.md for Ansible Projects
## Ansible Stack
- Version: Ansible Core 2.16+ with collections
- Collections: ansible.builtin, community.general, community.mysql, amazon.aws
- Roles: galaxy.yaml for dependencies, roles/ for custom roles
- Inventory: dynamic from AWS/GCP or static in inventory/
- Secrets: Ansible Vault (AES256) — vault password from ~/.vault_pass or CI env
- Templates: Jinja2 with strict undefined (ansible.cfg: jinja2_native=true)
- Testing: Molecule with Docker driver (molecule/default/)
- Lint: ansible-lint with production profile
- Style: FQCN for all tasks (ansible.builtin.copy not just copy)
Playbook Structure
ansible/
├── ansible.cfg
├── requirements.yml # Galaxy collections + roles
├── inventory/
│ ├── production/
│ │ ├── hosts.yml
│ │ └── group_vars/
│ │ ├── all/
│ │ │ ├── vars.yml
│ │ │ └── vault.yml # Encrypted secrets
│ │ └── webservers/
│ │ └── vars.yml
│ └── staging/
│ └── hosts.yml
├── playbooks/
│ ├── site.yml # Master playbook
│ ├── deploy-app.yml
│ └── rolling-update.yml
└── roles/
├── common/
├── nginx/
└── myapp/
# ansible.cfg
[defaults]
inventory = inventory/production
roles_path = roles
collections_paths = collections
vault_password_file = ~/.vault_pass
# Performance
forks = 20
pipelining = True
gathering = smart # Cache facts
# Output
stdout_callback = yaml
bin_ansible_callbacks = True
[ssh_connection]
ssh_args = -C -o ControlMaster=auto -o ControlPersist=60s
Master Playbook and Role Assignment
# playbooks/site.yml
---
- name: Apply common configuration to all hosts
hosts: all
become: true
roles:
- role: common
tags: [common, always]
- name: Configure web servers
hosts: webservers
become: true
roles:
- role: nginx
tags: [nginx, web]
- role: myapp
tags: [myapp, deploy]
- name: Configure database servers
hosts: databases
become: true
serial: 1 # One at a time for rolling updates
roles:
- role: postgresql
tags: [postgres, database]
# inventory/production/hosts.yml
all:
children:
webservers:
hosts:
web-01.prod.internal:
web-02.prod.internal:
web-03.prod.internal:
vars:
http_port: 443
app_version: "{{ lookup('env', 'APP_VERSION') | default('1.0.0') }}"
databases:
hosts:
db-01.prod.internal:
postgresql_role: primary
db-02.prod.internal:
postgresql_role: replica
Role Structure
roles/nginx/
├── defaults/
│ └── main.yml # Low-priority defaults
├── vars/
│ └── main.yml # High-priority vars
├── tasks/
│ ├── main.yml
│ ├── install.yml
│ └── configure.yml
├── handlers/
│ └── main.yml
├── templates/
│ ├── nginx.conf.j2
│ └── vhost.conf.j2
├── files/
│ └── dhparam.pem
├── meta/
│ └── main.yml # Role dependencies
└── molecule/
└── default/
├── molecule.yml
├── converge.yml
└── verify.yml
# roles/nginx/defaults/main.yml
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_keepalive_timeout: 65
nginx_server_tokens: "off"
nginx_gzip: true
nginx_gzip_types:
- text/plain
- text/css
- application/json
- application/javascript
- text/xml
nginx_vhosts: []
# Example:
# - name: myapp
# server_name: myapp.example.com
# root: /var/www/myapp
# ssl_cert: /etc/ssl/certs/myapp.crt
# ssl_key: /etc/ssl/private/myapp.key
# roles/nginx/tasks/main.yml
---
- name: Include OS-specific variables
ansible.builtin.include_vars: "{{ ansible_os_family | lower }}.yml"
tags: [always]
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
tags: [install]
- name: Ensure nginx config directory exists
ansible.builtin.file:
path: /etc/nginx/conf.d
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy main nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: Reload nginx
- name: Deploy virtual host configurations
ansible.builtin.template:
src: vhost.conf.j2
dest: "/etc/nginx/conf.d/{{ item.name }}.conf"
owner: root
group: root
mode: "0644"
loop: "{{ nginx_vhosts }}"
notify: Reload nginx
- name: Ensure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
# roles/nginx/handlers/main.yml
---
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: "Reload nginx"
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
listen: "Restart nginx"
Jinja2 Templates
{# 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;
server_tokens {{ nginx_server_tokens }};
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"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
{% if nginx_gzip %}
gzip on;
gzip_disable "msie6";
gzip_types {{ nginx_gzip_types | join(' ') }};
{% endif %}
include /etc/nginx/conf.d/*.conf;
}
{# roles/nginx/templates/vhost.conf.j2 #}
server {
listen 80;
server_name {{ item.server_name }};
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name {{ item.server_name }};
root {{ item.root | default('/var/www/html') }};
ssl_certificate {{ item.ssl_cert }};
ssl_certificate_key {{ item.ssl_key }};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
{% for location in item.locations | default([]) %}
location {{ location.path }} {
{% for directive in location.directives | default([]) %}
{{ directive }};
{% endfor %}
}
{% endfor %}
location / {
proxy_pass http://{{ item.upstream | default('127.0.0.1:3000') }};
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 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
Ansible Vault for Secrets
# inventory/production/group_vars/all/vault.yml
# Encrypted with: ansible-vault encrypt vault.yml
# Edit with: ansible-vault edit vault.yml
$ANSIBLE_VAULT;1.1;AES256
35303664613538383763326166643332386236353430633736333835383430306539346461626465
...
# Decrypted content:
vault_db_password: "super-secret-password"
vault_app_secret_key: "random-secret-key-here"
vault_stripe_secret: "sk_live_..."
# inventory/production/group_vars/all/vars.yml
# Reference vault vars without exposing values
db_password: "{{ vault_db_password }}"
app_secret_key: "{{ vault_app_secret_key }}"
app_config:
database_url: "postgresql://app:{{ vault_db_password }}@db-01.prod.internal/myapp"
secret_key: "{{ vault_app_secret_key }}"
Application Deployment Playbook
# playbooks/deploy-app.yml
---
- name: Deploy application with zero downtime
hosts: webservers
become: true
serial: "33%" # Deploy to 1/3 of servers at a time
vars:
app_release_dir: "/var/www/releases/{{ ansible_date_time.epoch }}"
app_current_link: /var/www/current
pre_tasks:
- name: Remove server from load balancer
community.general.haproxy:
state: disabled
host: "{{ inventory_hostname }}"
socket: /var/run/haproxy/admin.sock
wait: true
delegate_to: "{{ groups['load_balancers'][0] }}"
tasks:
- name: Create release directory
ansible.builtin.file:
path: "{{ app_release_dir }}"
state: directory
owner: deploy
group: deploy
mode: "0755"
- name: Deploy application code
ansible.builtin.unarchive:
src: "{{ lookup('env', 'ARTIFACT_URL') }}"
dest: "{{ app_release_dir }}"
remote_src: true
owner: deploy
group: deploy
- name: Install dependencies
community.general.npm:
path: "{{ app_release_dir }}"
state: present
production: true
- name: Run database migrations
ansible.builtin.command:
cmd: node_modules/.bin/db-migrate up
chdir: "{{ app_release_dir }}"
environment:
DATABASE_URL: "{{ app_config.database_url }}"
run_once: true # Only run once across all hosts
- name: Update symlink atomically
ansible.builtin.file:
src: "{{ app_release_dir }}"
dest: "{{ app_current_link }}"
state: link
force: true
notify: Restart app
- name: Wait for application to be healthy
ansible.builtin.uri:
url: "http://localhost:3000/healthz"
status_code: 200
retries: 10
delay: 3
register: health_check
- name: Clean old releases (keep last 5)
ansible.builtin.shell: |
ls -dt /var/www/releases/* | tail -n +6 | xargs rm -rf
changed_when: false
post_tasks:
- name: Re-add server to load balancer
community.general.haproxy:
state: enabled
host: "{{ inventory_hostname }}"
socket: /var/run/haproxy/admin.sock
delegate_to: "{{ groups['load_balancers'][0] }}"
Molecule Testing
# roles/nginx/molecule/default/molecule.yml
dependency:
name: galaxy
driver:
name: docker
platforms:
- name: ubuntu-22
image: ubuntu:22.04
pre_build_image: true
command: /lib/systemd/systemd
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:ro
- name: rocky-9
image: rockylinux:9
pre_build_image: true
command: /lib/systemd/systemd
privileged: true
provisioner:
name: ansible
config_options:
defaults:
callbacks_enabled: ansible.posix.profile_tasks
verifier:
name: ansible
# roles/nginx/molecule/default/verify.yml
---
- name: Verify nginx configuration
hosts: all
tasks:
- name: Verify nginx package is installed
ansible.builtin.package:
name: nginx
state: present
check_mode: true
register: pkg_check
failed_when: pkg_check.changed
- name: Verify nginx service is running
ansible.builtin.service:
name: nginx
state: started
enabled: true
check_mode: true
register: svc_check
failed_when: svc_check.changed
- name: Verify nginx responds on port 80
ansible.builtin.uri:
url: http://localhost
status_code: [200, 301, 302]
timeout: 10
For the Terraform and Pulumi infrastructure that Ansible configures after provisioning, see the Pulumi TypeScript guide for cloud resource provisioning. For the HashiCorp Vault integration that supplies secrets to Ansible playbooks, the Vault guide covers dynamic credential management. The Claude Skills 360 bundle includes Ansible skill sets covering role design, Vault secrets, and Molecule testing. Start with the free tier to try playbook generation.