Claude Code for Ansible: Playbooks, Roles, and Idempotent Automation — Claude Skills 360 Blog
Blog / Infrastructure / Claude Code for Ansible: Playbooks, Roles, and Idempotent Automation
Infrastructure

Claude Code for Ansible: Playbooks, Roles, and Idempotent Automation

Published: December 19, 2026
Read time: 9 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free