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.
- name
ansible-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.
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) beforecommand/shell. Ifshellis unavoidable, addcreates:,removes:, orchanged_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
| Task | Use | Avoid |
|---|---|---|
| Packages | ansible.builtin.apt / dnf / package with state: present | shell: apt-get install, state: latest in prod |
| Config files | template / copy with validate: | lineinfile chains on complex files |
| One line in a file you do not own | lineinfile / blockinfile | sed via shell |
| Services | systemd_service / service | shell: systemctl restart |
| Users, keys | user, ansible.posix.authorized_key | useradd via shell |
| Downloads | get_url with checksum: | curl via shell |
| One-time command | command 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/shellalways report changed unless told otherwise.state: latestsilently 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 %}. --checkskipscommand/shelltasks, so later tasks depending on their registered output may fail in check mode; setcheck_mode: falseon safe read-only commands.- Handlers run at the end of the play; use
ansible.builtin.meta: flush_handlerswhen a restart must happen before later tasks. no_log: truehides 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.