DEV Community

Cover image for Writing Idempotent Ansible Playbooks: Safe Change Patterns
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

Writing Idempotent Ansible Playbooks: Safe Change Patterns

A playbook returning changed=0 on its second run is a useful signal, but it is not proof of safe automation. A different input, target state, or external service can still produce a different outcome. A more precise goal is: with the same inputs and starting state, another run should create no new side effect; when a change is required, it should be verifiable and recoverable.

State-oriented modules such as package, file, template, user, and service make that goal easier. Free-form commands, external API calls, and custom changed_when expressions put the burden of idempotence back on the playbook author.

Describe State Before Procedures

Write a task as “this state must exist,” not merely “run this command.” These tasks request the same user and directory state on every run:

- name: Ensure application group exists
  ansible.builtin.group:
    name: myapp
    system: true

- name: Ensure application user exists
  ansible.builtin.user:
    name: myapp
    group: myapp
    system: true
    create_home: false

- name: Ensure configuration directory exists
  ansible.builtin.file:
    path: /etc/myapp
    state: directory
    owner: root
    group: myapp
    mode: "0750"
Enter fullscreen mode Exit fullscreen mode

Fully qualified collection names such as ansible.builtin.file remove ambiguity when multiple collections expose the same short module name. Quoting numeric file modes also avoids YAML interpretation surprises.

--check Is a Prediction, Not Proof

ansible-playbook site.yml --check --diff asks modules to predict changes without applying them. For modules that support it, --diff also shows file differences. This is valuable during review, but it has two important limits:

  • Modules do not all support check mode to the same extent.
  • A command that is not executed cannot reveal its real return code or external effect.

Do not read check-mode output as a guarantee of production behavior. Disable diff output when rendered content contains secrets:

- name: Render application secrets
  ansible.builtin.template:
    src: secrets.conf.j2
    dest: /etc/myapp/secrets.conf
    owner: root
    group: myapp
    mode: "0640"
  no_log: true
  diff: false
Enter fullscreen mode Exit fullscreen mode

The stronger convergence test is to apply the playbook twice in a disposable environment. The first run may make required changes; the second should report no unexpected change.

creates, removes, and Honest changed_when for Commands

ansible.builtin.command cannot generally know whether the program it launches is idempotent. For operations represented by a filesystem artifact, creates or removes can prevent unnecessary execution and provide partial check-mode support:

- name: Inspect schema marker
  ansible.builtin.stat:
    path: /var/lib/myapp/.schema-v2
  register: schema_v2

- name: Run schema migration once
  ansible.builtin.command:
    cmd: /opt/myapp/bin/migrate --to 2
    creates: /var/lib/myapp/.schema-v2
  when: not schema_v2.stat.exists
Enter fullscreen mode Exit fullscreen mode

The first task defines schema_v2 before the condition uses it. Trying to use a variable in a task's when clause while creating that variable with the same task's register is invalid: the result does not exist when the condition is evaluated.

Mark a read-only probe as unchanged explicitly:

- name: Read current application version
  ansible.builtin.command:
    cmd: /opt/myapp/bin/myapp --version
  register: myapp_version
  changed_when: false
  failed_when: myapp_version.rc != 0
Enter fullscreen mode Exit fullscreen mode

changed_when: false does not make a command safe. It only changes Ansible's report. If the command has a side effect, this expression merely hides the change.

Validate a File Before Replacing It

The template module's validate option runs a validation command against a temporary file before moving it to the destination. Ansible substitutes the temporary path for %s; the command is not run through a shell.

- name: Manage nginx configuration
  hosts: web
  become: true
  tasks:
    - name: Deploy nginx configuration after syntax validation
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        owner: root
        group: root
        mode: "0644"
        backup: true
        validate: /usr/sbin/nginx -t -c %s
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
Enter fullscreen mode Exit fullscreen mode

If the file is unchanged, the task does not report a change and the handler is not notified. Even when several tasks notify the same handler, it normally runs once at the end of the relevant play section. That timing matters: if a later task fails, a notified handler may not run under the default behavior. Decide deliberately whether the failure model calls for force_handlers or a controlled meta: flush_handlers; both alter execution semantics.

block and rescue Do Not Invent a Rollback

rescue runs after a task in its block enters the failed state. It does not catch every failure class, such as an unreachable host or an invalid task definition. A rollback is also fictional if no restorable artifact was created first.

- name: Deploy and verify nginx configuration
  block:
    - name: Install validated configuration
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        backup: true
        validate: /usr/sbin/nginx -t -c %s
      register: deployed_config

    - name: Verify installed configuration
      ansible.builtin.command:
        cmd: /usr/sbin/nginx -t
      changed_when: false

  rescue:
    - name: Restore the backup created by template
      ansible.builtin.copy:
        src: "{{ deployed_config.backup_file }}"
        dest: /etc/nginx/nginx.conf
        remote_src: true
      when: deployed_config.backup_file is defined

    - name: Verify restored configuration
      ansible.builtin.command:
        cmd: /usr/sbin/nginx -t
      changed_when: false
Enter fullscreen mode Exit fullscreen mode

This is a starting point, not a universal transaction. Service reload and traffic health checks still need environment-specific design. A rollback that has never been exercised is not reliable.

Separate False Idempotence Signals

Some playbooks look clean on a second run because they hide changes rather than prevent them. The common example is adding changed_when: false to a command with side effects. A template that embeds the current time, a random value, or a newly generated token creates the opposite problem: the template module correctly rewrites the file on every run. Fix the unstable input instead of masking the report.

state: latest is not inherently wrong, but it follows a different contract from “same inputs, same result.” When a repository publishes a new package, the task intentionally changes the host. Keep such expected drift separate from convergence tests that require pinned versions, and document the distinction.

For HTTP APIs, a filesystem creates marker may be insufficient. Use an idempotency key when the API supports one. Otherwise, read the current resource, compare it with the desired representation, and write only when they differ. If a request times out after reaching the server, query the resource before blindly repeating a POST.

When a remote marker or lock represents completion, create it atomically only after the operation and its verification succeed. A marker left by a failed operation can cause every later run to skip necessary recovery.

Measure the State After Applying It

An idempotent task result is not the same as a healthy service. A configuration file can be correct while the process cannot reach a new dependency. Add a read-only health check after the change and bound the startup window with explicit retries:

- name: Wait for local health endpoint
  ansible.builtin.uri:
    url: http://127.0.0.1:8080/health
    status_code: 200
    return_content: false
  register: health
  changed_when: false
  retries: 6
  delay: 5
  until: health.status == 200
Enter fullscreen mode Exit fullscreen mode

Retries must not become an infinite wait. When health verification fails, the playbook or its calling deployment layer should state which backup is restored, how the service is reloaded, and how the restored state is verified.

Test Convergence in CI

A safe pipeline should apply at least this sequence:

  1. Run ansible-playbook --syntax-check.
  2. Use ansible-lint to inspect risky patterns and module usage.
  3. Apply the playbook to an isolated test host.
  4. Apply the same playbook again with the same inputs.
  5. Fail on every failed result and every unexpected second-run changed result.
  6. Keep intentional probes separate with an honest changed_when: false.

A successful first run is not enough. Evaluate second-run convergence, handler counts, rendered-file differences, and service health together.

When Does This Decision Become Invalid?

Re-test an idempotence claim when the module version, target package manager, external API behavior, template inputs, or service assumptions change. A latest package state, a time-dependent API, or a template that generates random values can make the same playbook produce a later change.

The objective is not to force every task to display changed=0. The objective is automation that reports real changes honestly, validates before replacement, measures after application, and follows a rollback path that has actually been tested.

Official References

Top comments (0)