Every run, the same handful of tasks light up yellow: changed. Nothing on the host actually changed. It’s the kind of noise you learn to scroll past — until the day a real change hides in the wall of false ones, or a handler fires a needless service restart in production because a task cried wolf. Broken idempotency isn’t cosmetic. It’s a broken signal.
Idempotency is the whole promise
The entire value proposition of a configuration tool is convergence: run it against a host already in the desired state and it should report ok, do nothing, and change nothing. A task that reports changed on a converged host has broken that promise — and worse, it can no longer tell you when something genuinely drifted, because it says the same thing either way.
The usual culprit: command and shell
The command and shell modules have no idea what “changed” means. They run something and, unless told otherwise, report changed every single time by definition. Most false positives trace back to shelling out where a real module — or a proper guard — belonged.
# The trap: shell has no concept of state, so it's always "changed"
- name: Ensure the app user exists
ansible.builtin.shell: useradd appuser
# Reports changed forever, and errors out the second time the user exists.
# Fix 1 - use the module that actually understands desired state
- name: Ensure the app user exists
ansible.builtin.user:
name: appuser
state: present # idempotent by design: creates once, then reports ok
# Fix 2 - when you MUST shell out, tell Ansible how to judge the result
- name: Rebuild the search index only when the marker is stale
ansible.builtin.command: /opt/app/reindex.sh
args:
creates: /var/lib/app/.reindexed # skip entirely if this file exists
# Fix 3 - define "changed" yourself from the command's own output
- name: Apply schema migrations
ansible.builtin.command: migrate --apply
register: migrate_out
changed_when: "'Applied' in migrate_out.stdout"
failed_when: migrate_out.rc != 0 and 'up to date' not in migrate_out.stderr
Prove it, don’t assume it
You don’t have to eyeball the yellow. Ansible has a built-in idempotency test: run the play, then run it again in check mode and treat any changed as a failure.
ansible-playbook site.yml # converge
ansible-playbook site.yml --check --diff # second run should be all "ok"
# In CI, assert zero changed tasks on the second pass - that's your regression gate.
Wire that second pass into CI and idempotency stops being something you hope for and becomes something you enforce. A playbook that isn’t green on the second run doesn’t converge, and a playbook that doesn’t converge can’t be trusted to tell you the truth about your fleet.
The lesson
A changed you can’t trust is worse than no signal at all, because it looks like information. Make every task earn its status: reach for the stateful module first, guard your commands with creates/removes, and define changed_when whenever you shell out. The goal isn’t a quiet run for its own sake — it’s a run where yellow always means something.
Example plays and the CI idempotency gate are on GitHub: github.com/waghmaredb/vexpose-labs. Have a favorite idempotency trick I skipped? Share it on LinkedIn or X.
Leave a Reply