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"
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
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
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
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
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
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
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:
- Run
ansible-playbook --syntax-check. - Use
ansible-lintto inspect risky patterns and module usage. - Apply the playbook to an isolated test host.
- Apply the same playbook again with the same inputs.
- Fail on every
failedresult and every unexpected second-runchangedresult. - 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.
Top comments (0)