A task reports changed on every run when it should be idempotent — diagnose and fix it.
A playbook is meant to be idempotent, but every ansible-playbook run reports the same tasks as changed even though the host is already configured. Study the play and the repeated recap, then explain the cause and fix it.
- name: Configure app host
hosts: web
become: true
tasks:
- name: Ensure release directory exists
shell: "mkdir -p /opt/app/releases"
- name: Fetch the release archive
shell: "curl -sSL https://cdn.example.com/app-1.4.2.tgz -o /opt/app/app.tgz"
- name: Install nginx
command: "apt-get install -y nginx"
$ ansible-playbook site.yml
web01 : ok=4 changed=3 unreachable=0 failed=0
$ ansible-playbook site.yml # nothing changed on the host between runs
web01 : ok=4 changed=3 unreachable=0 failed=0
Why does it report changed=3 every time, and how do you make it idempotent?
Every task uses shell/command, which just runs and reports changed unconditionally — Ansible cannot see the state behind a raw command, so it never converges. Fix it with native, idempotent modules: file: state=directory, get_url, and apt: name=nginx state=present. When a command is unavoidable, guard it with creates: or changed_when:.
- ✗Blaming fact cache or
becomerather than the rawshell/command - ✗Thinking
registeralone makes acommandidempotent - ✗Not replacing commands with
file/get_url/aptstate modules
- →What does
changed_when: falsedo for an inherently read-only command? - →How would
--checkmode behave on the module version of this play?
Read the recap first: changed=3 on every run when nothing changed on the host is the fingerprint of a non-idempotent task. All three tasks use shell/command, and such a module simply executes a string and always reports changed: Ansible cannot look behind a raw command to see there is nothing left to do. That is an imperative script, not a declared state.
The fix is to declare the desired state with native modules that check the host's current state themselves and change it only when it diverges:
- name: Configure app host
hosts: web
become: true
tasks:
- name: Ensure release directory exists
ansible.builtin.file:
path: /opt/app/releases
state: directory
- name: Fetch the release archive
ansible.builtin.get_url:
url: "https://cdn.example.com/app-1.4.2.tgz"
dest: /opt/app/app.tgz
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
Now a second run yields changed=0: file sees the directory already exists; get_url skips the already-downloaded file; apt sees nginx is present. When no module fits and a raw command is unavoidable, restore idempotency with a guard: creates: /path/marker (skip if the file exists), when: (a condition), or changed_when: false for read-only commands. Catch this in --check (dry-run) — the module version honestly reports changed=0 there.