How to Write an Idempotent Ansible Playbook
An idempotent Ansible playbook declares the desired state of a host using modules like ansible.builtin.file and ansible.builtin.template - running it twice is safe because Ansible checks before it changes anything. Here's how to build one.
What idempotency means in Ansible
Ansible modules describe state, not actions. The file module says "this
directory should exist with these permissions" - not "run mkdir." On the first
run Ansible creates what's missing. On the second run it checks the current
state, finds nothing has drifted, and reports changed=0. That's idempotency,
and it's the foundational principle that makes Ansible safe to run repeatedly
in production.
A working playbook: file + template tasks
The playbook below targets localhost, declares two directories and renders two
files from Jinja2 templates. It is idempotent because every task uses a
declarative module - nothing calls shell: or command: to run a raw script.
---
- name: Bootstrap the API stack
hosts: localhost
connection: local
gather_facts: false
vars:
service_name: API
tasks:
- name: Ensure /srv/api exists
ansible.builtin.file:
path: /srv/api
state: directory
owner: root
group: root
mode: '0755'
- name: Render /srv/api/index.html
ansible.builtin.template:
src: index.html.j2
dest: /srv/api/index.html
owner: root
group: root
mode: '0644'
- name: Ensure /etc/nginx/conf.d exists
ansible.builtin.file:
path: /etc/nginx/conf.d
state: directory
owner: root
group: root
mode: '0755'
- name: Render nginx site config
ansible.builtin.template:
src: api.conf.j2
dest: /etc/nginx/conf.d/api.conf
owner: root
group: root
mode: '0644'
The matching Jinja2 template templates/index.html.j2 uses the service_name
variable from the play's vars: block:
<h1>{{ service_name }} Ready</h1>
Running and verifying idempotency
Run the playbook twice against localhost using a comma-delimited inline inventory (no inventory file needed):
# First run - creates the directories and renders the files
ansible-playbook site.yaml -i 'localhost,' --connection=local
# Second run - reports changed=0 if everything is idempotent
ansible-playbook site.yaml -i 'localhost,' --connection=local
The second run should output changed=0 unreachable=0 failed=0. If changed
is non-zero, a task is not idempotent - often a shell: or command: task
that Ansible cannot diff.
Common mistakes that break idempotency
- Using
shell:orcommand:withoutcreates:orchanged_when- these modules always report changed. Addcreates: /path/to/artifactso Ansible skips the task when the artifact already exists. - Forgetting
state: directory-ansible.builtin.filedefaults tostate=file; withoutstate: directoryit won't create a directory. - Hardcoding mode as an integer - use a quoted string (
'0755') to avoid octal interpretation bugs across Python versions.
Scaling up: roles and variables
For real infrastructure, split tasks into reusable roles (roles/nginx/tasks/main.yaml),
pull variables from group_vars/ or host_vars/, and drive playbooks from a
control host or ansible-pull in agent mode. Terraform outputs can feed Ansible
inventory so provisioned infrastructure flows directly into configuration management.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Using ansible.builtin.file to declare directory state (owner, mode, state=directory)
- Rendering Jinja2 templates to files with ansible.builtin.template
- Verifying idempotency by running the playbook twice and confirming changed=0
FAQ
What makes an Ansible playbook idempotent?
Using declarative modules (file, template, copy, package, service) that check current state before making changes. Running the playbook twice is safe - the second run reports changed=0 because nothing has drifted.
How do I verify my Ansible playbook is idempotent?
Run it twice with ansible-playbook site.yaml -i 'localhost,' --connection=local. The second run must report changed=0. If it doesn't, a task - often a shell: or command: call - is not checking for existing state.
How does ansible.builtin.template work?
It renders a Jinja2 template file (ending in .j2) to a destination path on the target host, injecting variables from the play's vars:, inventory, or extra-vars. It only writes the file when the rendered content differs from what's already on disk.
Is Ansible idempotent?
Ansible is designed to be idempotent: most modules check current state and only change what is needed, so a second run reports changed=0. It is not automatic, though - raw shell/command tasks can break it unless you guard them.
How do you ensure idempotency in Ansible?
Use state-based modules (file, template, copy, package) instead of raw shell, guard any command task with creates/removes or changed_when, and re-run the playbook to confirm it reports changed=0.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →