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.

DevOps Engineeransibleconfiguration-managementplaybook

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

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

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

Fix the Nginx 502 Bad GatewayDevOps projectRecover a Crashed Linux ServiceDevOps projectCorrect a Postgres Port MismatchDevOps projectDevOps roadmapStep by step to hiredDevOps interview questionsSTAR answersAll DevOps projectsProjects hub

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 →