Claude Code with Ansible: Idempotent Playbooks by Default
Why Ansible without CLAUDE.md ships playbooks you cannot safely re-run
Ansible's central promise is idempotency: run the playbook once and the system reaches the desired state, run it ten more times and nothing changes. That promise holds only when every task uses a module that knows how to check current state before acting. The moment a playbook reaches for the shell or command module to do something a real module already handles, idempotency is gone. The task runs every time, reports changed every time, and may do real damage every time. The playbook still works on a fresh host, which is exactly why the problem hides until the second run on a host that already has state.
A Claude Code session that writes Ansible without project rules reaches for shell and command constantly, because they are the most flexible modules and the ones that map most directly onto the bash a model already knows. It writes command: useradd appuser instead of the user module, so the task fails on the second run because the user already exists. It writes shell: systemctl restart nginx with no handler, so the service restarts on every run whether or not the config changed. It hardcodes a database password in a vars block because that is the path of least resistance. It writes a playbook with no check_mode support, so there is no way to preview what a run would do before it does it. Each of these makes the playbook a little less safe to run, and together they describe automation an operator has to run carefully by hand forever.
This guide covers the CLAUDE.md configuration that locks Claude Code into Ansible patterns that stay safe to re-run: real modules over shell for everything that has one, handlers for service restarts, vault for every secret, check mode and diff as the default way to preview a run, and permission hooks that keep Claude from running a playbook against production hosts during development. For the cloud provisioning layer that creates the hosts Ansible configures, Claude Code with Terraform covers the infrastructure-as-code side. For the container runtime Ansible deploys onto, Claude Code with Docker covers the image patterns.
The Ansible CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For an Ansible repository it needs to declare the directory layout, the module-over-shell policy, the vault convention, the check-mode requirement, the role testing setup, and the hard rules that block the antipatterns Claude reaches for most often.
# Ansible rules
## Stack
- ansible-core 2.x, collections pinned in requirements.yml
- Roles under roles/, playbooks under playbooks/
- Inventory split by environment: inventory/production, inventory/staging
- ansible-lint and yamllint run in CI, both must pass
## Directory layout
- playbooks/ , top-level playbooks, one per intent
- roles/<role>/tasks/ , task files, main.yml is the entrypoint
- roles/<role>/handlers/ , handlers for restarts and reloads
- roles/<role>/defaults/ , default variables (lowest precedence)
- roles/<role>/templates/ , Jinja2 templates
- group_vars/ and host_vars/ , inventory-scoped variables
- inventory/<env>/ , per-environment inventory and group_vars
## Modules over shell (MANDATORY)
- Use the real module for the job, NEVER shell/command when one exists
- user, group, package, service, copy, template, lineinfile, file, git
- shell/command ONLY for operations with no module, and MUST set:
- changed_when to report change state correctly
- creates or removes for idempotency where applicable
- NEVER use shell to do what a module does idempotently
## Handlers (MANDATORY)
- Service restarts and reloads go through handlers, NEVER inline
- Tasks notify handlers, handlers run once at the end of the play
- Handler names are descriptive: "restart nginx", not "handler1"
## Secrets (MANDATORY)
- Every secret stored in ansible-vault, NEVER plaintext in vars
- Vault password from a file referenced by ANSIBLE_VAULT_PASSWORD_FILE
- vars files split: vars.yml (plain) and vault.yml (encrypted)
- NEVER commit an unencrypted vault.yml
## Safety (MANDATORY)
- Every role supports check mode (--check) and reports diff (--diff)
- Destructive tasks gated behind a confirmation variable
- serial set on plays that touch a fleet, to roll out gradually
- any_errors_fatal on plays where partial failure is worse than a stop
## Testing
- Each role has a Molecule scenario under roles/<role>/molecule/
- Molecule converge runs the role, verify asserts the end state
- Idempotency test: second converge must report zero changes
## Hard rules
- NEVER use shell/command when a real module exists
- NEVER restart a service inline, always via a handler
- NEVER store a secret in plaintext, always vault
- NEVER write a task that is not idempotent on the second run
- NEVER run a playbook against production from a dev session
- ALWAYS set changed_when on shell/command tasks
- ALWAYS run --check --diff before a real run
- ALWAYS keep a role's second converge at zero changes
Six rules here prevent the majority of incidents Claude generates without them.
The modules-over-shell rule is the one that protects idempotency. Without it, Claude reaches for shell and command because they are flexible and they map onto the bash commands a model knows. The rule forces the question "is there a module for this?" before any shell task, and the answer is almost always yes. The user module instead of useradd, the package module instead of apt install, the service module instead of systemctl. Each real module checks current state before acting, which is what makes the second run a no-op.
The handlers rule stops the restart-every-time pattern. When a task restarts a service inline, the restart happens on every run, which causes unnecessary downtime and masks whether the config actually changed. The rule routes every restart through a handler that a task notifies. The handler runs once at the end of the play, and only if something notified it, so the service restarts only when its config genuinely changed.
The vault rule keeps secrets out of source control. The path of least resistance is a plaintext password in a vars block, which Claude generates because it is the simplest thing that works. The rule splits variables into a plain vars.yml and an encrypted vault.yml, so secrets are encrypted at rest and the unencrypted form never reaches the repository.
Directory layout and a first role
Initialize the repository structure and create a role with the standard subdirectories.
mkdir -p playbooks inventory/{production,staging}
ansible-galaxy init roles/webserver
The ansible-galaxy init command scaffolds a role with tasks, handlers, defaults, templates, files, vars, and meta directories. Most roles use a subset. A web server role typically needs tasks, handlers, defaults, and templates.
# roles/webserver/tasks/main.yml
---
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Deploy nginx site config
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/sites-available/{{ site_name }}.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: reload nginx
- name: Enable nginx site
ansible.builtin.file:
src: /etc/nginx/sites-available/{{ site_name }}.conf
dest: /etc/nginx/sites-enabled/{{ site_name }}.conf
state: link
notify: reload nginx
- name: Ensure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
Every task here uses a real module. The package module installs nginx and is a no-op if it is already present. The template module renders the config and only reports changed if the rendered content differs from what is on disk, and the validate parameter runs nginx -t before replacing the file, so a broken template never lands. The file module creates the symlink idempotently. The service module ensures nginx is running and enabled. The two notify lines tell Ansible to run the reload handler, but only if those tasks changed something.
Handlers for restarts and reloads
The handler is defined once and notified by any task that changes the config it depends on.
# roles/webserver/handlers/main.yml
---
- name: reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
- name: restart nginx
ansible.builtin.service:
name: nginx
state: restarted
A handler runs at the end of the play, once, regardless of how many tasks notified it. A task that changes the site config notifies reload nginx. If three tasks all change config in one run, the handler still runs once. If nothing changed, the handler does not run at all, so the service is left alone. This is the behaviour the CLAUDE.md handler rule exists to produce: restarts that happen exactly when they are needed and never when they are not.
The choice between reload and restart matters. A reload re-reads config without dropping connections, which is right for nginx config changes. A restart drops connections and is reserved for changes that a reload cannot pick up. Name handlers descriptively so the notify line reads as intent.
When shell is unavoidable
Some operations have no module. When you must use shell or command, the CLAUDE.md rule requires changed_when and an idempotency guard.
- name: Run database migration
ansible.builtin.command:
cmd: /opt/app/bin/migrate up
register: migrate_result
changed_when: "'applied' in migrate_result.stdout"
failed_when: migrate_result.rc != 0
run_once: true
- name: Generate dhparam if missing
ansible.builtin.command:
cmd: openssl dhparam -out /etc/nginx/dhparam.pem 2048
creates: /etc/nginx/dhparam.pem
The migration task uses changed_when to report changed only when the output indicates a migration was actually applied, so a run with nothing to migrate correctly reports ok rather than changed. The run_once: true ensures it runs against a single host even when the play targets a fleet, which prevents concurrent migrations. The dhparam task uses creates, which tells Ansible to skip the command entirely if the file already exists, making an inherently slow and non-idempotent command behave idempotently. These two guards, changed_when and creates, are what turn an unavoidable shell task into one that is safe to re-run.
Secrets with Ansible Vault
Every secret goes in an encrypted vault file. The pattern splits variables into a plain file and an encrypted file, with the plain file referencing the encrypted values.
# create the encrypted vault file
ansible-vault create roles/webserver/vars/vault.yml
# roles/webserver/vars/vault.yml (encrypted at rest)
---
vault_db_password: "the-real-secret"
vault_api_token: "another-real-secret"
# roles/webserver/vars/main.yml (plain, references the vault)
---
db_password: "{{ vault_db_password }}"
api_token: "{{ vault_api_token }}"
The encrypted vault.yml holds the actual secrets and is committed in encrypted form. The plain main.yml maps friendly names onto the vault_-prefixed values, which keeps the rest of the role referencing db_password without needing to know it comes from a vault. The vault password itself lives in a file referenced by ANSIBLE_VAULT_PASSWORD_FILE so no one types it interactively in CI. The prefix convention makes it obvious at a glance which variables are sensitive, which helps a reviewer catch a secret that slipped into the plain file. For the secret store that backs this in a larger setup, Claude Code with Terraform covers provisioning a managed secrets manager.
Check mode and diff
The CLAUDE.md rule requires that every run can be previewed. Check mode runs the playbook without making changes and reports what it would do. Diff mode shows the content changes.
# preview what a run would change, with content diffs
ansible-playbook playbooks/site.yml \
-i inventory/production \
--check --diff
# run for real after reviewing the preview
ansible-playbook playbooks/site.yml \
-i inventory/production \
--diff
The --check flag puts every module into check mode, where it reports whether it would change something but does not act. The --diff flag shows the before-and-after for templates and files. Together they answer "what will this run do?" before it does anything, which is the single most important safety habit with Ansible. For this to work, every module in the role must support check mode, which the real modules do by default and which is one more reason to avoid raw shell tasks, since a shell task cannot know what it would do without running.
For tasks that genuinely cannot run in check mode, mark them so a check run does not produce misleading output:
- name: Slow integrity scan
ansible.builtin.command:
cmd: /opt/app/bin/scan
changed_when: false
check_mode: false
Rolling deployment across a fleet
A play that touches many hosts should roll out gradually so a bad change does not hit the whole fleet at once. The serial keyword controls the batch size.
# playbooks/deploy.yml
---
- name: Deploy application to web fleet
hosts: webservers
serial: "25%"
max_fail_percentage: 10
become: true
roles:
- role: webserver
- role: app
The serial: "25%" rolls the deployment out to a quarter of the fleet at a time, waiting for each batch to complete before starting the next. If a batch fails, the deployment stops, so a broken change reaches at most a quarter of the hosts rather than all of them. The max_fail_percentage: 10 allows for a small number of host-level failures within a batch before aborting, which tolerates the occasional unreachable host without halting the whole rollout. This is the controlled-rollout pattern the CLAUDE.md safety rule encodes, and it is the difference between a deploy that fails safely and one that takes down the fleet.
Permission hooks for Ansible operations
Ansible playbooks change real infrastructure. A Claude Code session that runs a playbook against production while developing can cause an outage. Permission hooks in .claude/settings.local.json keep the dangerous runs behind a gate.
{
"permissions": {
"allow": [
"Bash(ansible-lint*)",
"Bash(yamllint*)",
"Bash(ansible-playbook*--check*)",
"Bash(molecule test*)",
"Bash(ansible-inventory*--list*)"
],
"deny": [
"Bash(ansible-playbook*inventory/production*)",
"Bash(ansible*-i*production*)",
"Bash(ansible-vault decrypt*)",
"Bash(ansible*--limit*all*)"
]
}
}
The allow list permits the operations Claude needs for development: linting, check-mode runs, Molecule tests, and inventory inspection, none of which change a real host. The deny list catches the operations that change production: any playbook run against the production inventory, any vault decrypt that would expose secrets, and any run that targets all hosts at once. These should be run by a human who is watching, not as a step in an agent loop. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can write and test playbooks without the ability to run one against production by accident.
Testing roles with Molecule
A role without a test is a role that breaks silently. Molecule spins up a container, runs the role, and asserts on the end state, including the critical idempotency check.
# roles/webserver/molecule/default/molecule.yml
---
driver:
name: docker
platforms:
- name: instance
image: geerlingguy/docker-ubuntu2204-ansible
pre_build_image: true
provisioner:
name: ansible
verifier:
name: ansible
# roles/webserver/molecule/default/verify.yml
---
- name: Verify
hosts: all
tasks:
- name: Check nginx is running
ansible.builtin.service_facts:
- name: Assert nginx service is active
ansible.builtin.assert:
that:
- ansible_facts.services['nginx.service'].state == 'running'
The Molecule scenario provisions a container, runs the role via converge, then runs verify.yml to assert the end state. The most valuable part is the idempotency check that molecule test runs automatically: it converges the role a second time and fails if any task reports changed. A role that passes this check is one that is genuinely safe to re-run, which is the whole point of the CLAUDE.md idempotency rule. For the CI pipeline that runs Molecule on every change, Claude Code with GitHub Actions covers the workflow setup.
Building Ansible that stays safe to re-run
The Ansible CLAUDE.md in this guide produces a repository where every task uses a real module over raw shell, every service restart runs through a handler that fires only on change, every secret lives in an encrypted vault, every run can be previewed with check mode and diff, fleet deployments roll out gradually, and every role passes an idempotency test. The result is automation an operator can run a hundred times in a row and trust that the system stays in the state the playbook describes.
The underlying principle is the same as any infrastructure built with Claude Code. Ansible without a CLAUDE.md produces playbooks that work on a fresh host and break on the second run, because raw shell tasks and inline restarts are the readable defaults a model reaches for. The CLAUDE.md is what makes the idempotent, check-mode-safe patterns the default for Claude.
For the cloud provisioning that creates the hosts Ansible configures, Claude Code with Terraform covers the infrastructure-as-code layer. For the container images Ansible deploys, Claude Code with Docker covers the build patterns. For the CI pipeline that lints and tests every playbook change, Claude Code with GitHub Actions covers the automation that keeps the repository honest.
Get Claudify. Ship Ansible playbooks that stay idempotent on the hundredth run, not just the first.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify