DevOps & SRE No. 146

Ansible Playbooks skill for Claude Code

Second run, zero changes. Claude writes roles that use proper modules with validation instead of shell one-liners, routes restarts through handlers, and keeps secrets in Vault with no_log. Every change is linted, dry-run with check and diff on one host, applied as a canary and then in serial batches, and finally re-run to prove the recap shows changed=0 everywhere.

  • nameansible-playbooks
  • size223 lines · ≈1,850 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes idempotent Ansible playbooks and roles — FQCN modules instead of shell, handlers, inventories with group_vars and host_vars, Ansible Vault secrets, rolling updates with serial, and check/diff dry runs — then proves idempotency with a second run and ansible-lint. Use when writing or fixing playbooks, roles, or inventories, when a play reports changed on every run, when rolling a config change across servers, or when encrypting variables with ansible-vault.

Try asking

  • This playbook reports changed on every run. Make it idempotent
  • Write a role that installs and configures nginx on our Ubuntu servers
  • Roll this config change across 40 web servers without taking the site down

Use it when

  • You are writing or refactoring playbooks, roles, or inventories
  • Tasks report changed even when nothing changed
  • A config change must roll across many servers safely
  • Secrets need to be encrypted with ansible-vault

Skip it when

  • You are creating cloud resources rather than configuring servers — use terraform-iac instead
  • The workload runs in containers on Kubernetes — use kubernetes-deploy instead

How to check the result

ansible-lint passes, a check and diff run on one host shows only intended changes, and running the playbook a second time reports changed=0 on every host.

The common mistake it prevents

Using shell or command for tasks a module already handles, which reports changed on every run and hides real drift.

~/.claude/skills/ansible-playbooks/SKILL.md

--- name: ansible-playbooks description: Writes idempotent Ansible playbooks and roles — FQCN modules instead of shell, handlers, inventories with group_vars and host_vars, Ansible… license: MIT ---

A playbook is done when a second run reports changed=0, --check --diff shows exactly what will change before it changes, secrets are vaulted and never logged, and a fleet-wide change rolls in batches that stop on failure.

Ground rules

  • Use a purpose-built module with its FQCN (ansible.builtin.apt, ansible.builtin.template) before command/shell. If shell is unavoidable, add creates:, removes:, or changed_when: so it stays idempotent.
  • Always dry-run first against a limited host set: --check --diff --limit <host>.
  • Running against production inventories (without --check) requires explicit user confirmation, stating the inventory, limit, and tags.
  • Secrets live in Vault-encrypted files or strings; tasks touching them use no_log: true.
  • Restart services through handlers, not unconditional tasks.

Workflow

- [ ] 1. Inspect inventory, ansible.cfg, existing roles, ansible-core version
- [ ] 2. Write/modify tasks in a role with defaults
- [ ] 3. Lint (ansible-lint) and syntax-check
- [ ] 4. Dry run: --check --diff on one host
- [ ] 5. Apply to one host (canary), then rolling batches
- [ ] 6. Re-run: expect changed=0
- [ ] 7. Report recap per host

1. Inspect

ansible --version                                   # ansible-core version and config file in use
ansible-inventory -i inventories/prod/hosts.yml --graph
ansible-inventory -i inventories/prod/hosts.yml --host web1   # merged vars for a host
ansible all -i inventories/prod/hosts.yml -m ansible.builtin.ping --limit web1

2. Layout

ansible/
├── ansible.cfg
├── requirements.yml              # collections and roles, pinned
├── site.yml
├── inventories/
│   ├── staging/{hosts.yml,group_vars/,host_vars/}
│   └── prod/{hosts.yml,group_vars/all/{vars.yml,vault.yml},host_vars/}
└── roles/
    └── nginx/
        ├── defaults/main.yml     # every tunable, lowest precedence
        ├── tasks/main.yml
        ├── handlers/main.yml
        ├── templates/nginx.conf.j2
        └── meta/main.yml
# ansible.cfg
[defaults]
inventory = inventories/staging/hosts.yml
roles_path = roles
forks = 20
callback_result_format = yaml
retry_files_enabled = False

[ssh_connection]
pipelining = True

Idempotent role example

# roles/nginx/tasks/main.yml
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
    cache_valid_time: 3600

- name: Deploy nginx.conf
  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: Ensure nginx is enabled and running
  ansible.builtin.systemd_service:
    name: nginx
    enabled: true
    state: started

- name: Read current app version (read-only, safe in check mode)
  ansible.builtin.command: cat /opt/app/VERSION
  register: app_version
  changed_when: false
  check_mode: false
  failed_when: false
# roles/nginx/handlers/main.yml
- name: Reload nginx
  ansible.builtin.systemd_service:
    name: nginx
    state: reloaded

validate: nginx -t -c %s works only when the template is a complete nginx.conf; for included snippets, validate after deployment with a handler that runs nginx -t first.

Module choice

TaskUseAvoid
Packagesansible.builtin.apt / dnf / package with state: presentshell: apt-get install, state: latest in prod
Config filestemplate / copy with validate:lineinfile chains on complex files
One line in a file you do not ownlineinfile / blockinfilesed via shell
Servicessystemd_service / serviceshell: systemctl restart
Users, keysuser, ansible.posix.authorized_keyuseradd via shell
Downloadsget_url with checksum:curl via shell
One-time commandcommand with creates:bare shell

3. Lint

ansible-galaxy collection install -r requirements.yml
ansible-lint                                  # production profile catches most idempotency issues
ansible-playbook site.yml --syntax-check

4–5. Dry run, canary, rolling apply

ansible-playbook -i inventories/prod/hosts.yml site.yml --check --diff --limit web1 --tags nginx
ansible-playbook -i inventories/prod/hosts.yml site.yml --limit web1 --tags nginx      # canary, after confirmation
ansible-playbook -i inventories/prod/hosts.yml site.yml --limit webservers --tags nginx

Rolling strategy inside the play:

- name: Web tier
  hosts: webservers
  become: true
  serial: [1, "25%"]          # one canary, then 25% batches
  max_fail_percentage: 0
  pre_tasks:
    - name: Drain from load balancer
      ansible.builtin.uri:
        url: "http://{{ lb_host }}/admin/drain/{{ inventory_hostname }}"
        method: POST
      delegate_to: localhost
  roles:
    - nginx
  post_tasks:
    - name: Wait for health
      ansible.builtin.uri:
        url: "http://{{ inventory_hostname }}/healthz"
      register: health
      until: health.status == 200
      retries: 20
      delay: 3

6. Prove idempotency

Run the same command twice. The second PLAY RECAP must show changed=0 for every host. Any task that changes on the second run is a bug: add changed_when, use a proper module, or fix the template (timestamps, unordered dict rendering).

Vault

ansible-vault create inventories/prod/group_vars/all/vault.yml
ansible-vault encrypt_string --vault-id prod@prompt 's3cr3t' --name 'vault_db_password'
ansible-playbook site.yml --vault-id prod@~/.config/ansible/prod.pass   # file outside repo, mode 600
ansible-vault view inventories/prod/group_vars/all/vault.yml

Convention: vault.yml holds vault_db_password; vars.yml maps db_password: "{{ vault_db_password }}", so grep shows where every secret is used.

Variable precedence (the parts that matter)

Lowest to highest: role defaults/ -> inventory group_vars/all -> group_vars/<group> -> host_vars/<host> -> play vars -> role vars/ -> set_fact/register -> -e extra vars. Put tunables in role defaults; avoid role vars/ for anything users override.

Gotchas

  • command/shell always report changed unless told otherwise.
  • state: latest silently upgrades packages on every run; pin versions for prod.
  • In when:, write bare expressions (when: app_enabled), never {{ }}. ansible-core 2.19 is stricter: conditionals must evaluate to booleans, not non-empty strings.
  • Quote Jinja at the start of a YAML value: path: "{{ base }}/app".
  • Templates of files containing {{ (Go templates, Prometheus rules) need {% raw %}.
  • --check skips command/shell tasks, so later tasks depending on their registered output may fail in check mode; set check_mode: false on safe read-only commands.
  • Handlers run at the end of the play; use ansible.builtin.meta: flush_handlers when a restart must happen before later tasks.
  • no_log: true hides output on failure too; debug with a non-secret dry run.

Output

**Scope:** inventories/prod, --limit webservers, --tags nginx (12 hosts, serial 1 then 25%)
**Dry run:** 12 hosts, 2 tasks would change (nginx.conf, reload handler)
**Apply:** ok=96 changed=24 failed=0 unreachable=0
**Idempotency rerun:** changed=0 on all hosts
**Lint:** ansible-lint production profile, 0 violations

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc