Skip to content

14. Failure modes

Symptoms first, because that is how you meet them.

Symptom. Exit code 0, a recap with no hosts in it, and no change on any machine.

[WARNING]: Could not match supplied host pattern, ignoring: webserver
PLAY [Configure web tier] ****************
skipping: no hosts matched
PLAY RECAP *******************************

Cause. The host pattern matched nothing — a typo in a group name, the wrong inventory, or a --limit that intersects to nothing. Ansible treats this as a warning and exits 0.

Fix.

Terminal window
ansible-inventory -i inventory/production --graph
ansible webservers --list-hosts

And in CI, assert that work happened rather than trusting the exit code — a PLAY RECAP with no hosts is a failed deployment that reported success.

2. A variable will not change no matter where I set it

Section titled “2. A variable will not change no matter where I set it”

Symptom. You set nginx_port in group_vars, in host_vars, in the play. It stays at the old value.

Causes, in order of likelihood:

a. it is in the role's vars/main.yml, not defaults/
→ precedence 15 beats every group_vars (chapter 6)
b. something did a set_fact on it
→ precedence 19, beats nearly everything
c. it is being passed with -e somewhere
→ precedence 22, beats everything
d. the group_vars file is named after a group
that does not exist (webserver vs webservers)
→ loads for nobody, silently

Find it.

Terminal window
ansible-inventory --host web1.example.com # merged vars
ansible-playbook site.yml -vvv | grep -i nginx_port
grep -rn "nginx_port" roles/ group_vars/ host_vars/

Fix. (a) is the common one and the fix is to move the variable to defaults/main.yml, where callers can override it.

Symptom. The config file is updated. The service is not restarted.

Causes:

a. the task reported `ok`, not `changed`
→ nothing was notified. This is correct behaviour
and it is the answer 80% of the time.
b. a later task failed
→ handlers do not run on a failed host
(chapter 4)
c. the handler name does not match the notify
string, exactly, including case
d. the handler is in a different play
→ notifications do not cross plays
e. you expected it to run at that point in the play
→ handlers run at the END

Fix. For (b), --force-handlers, or force_handlers: true on the play. For (e), meta: flush_handlers. For (a), confirm with --diff that the file genuinely did not change — if it did not, the handler is right not to run.

Symptom. Nothing changed on the machine, and the playbook says otherwise.

Cause. Non-idempotent tasks (chapter 5). Almost always:

command / shell with no changed_when
lineinfile whose regexp matches what it writes
replace with a self-matching pattern
template rendering a timestamp or a random value
file: mode set without a leading zero — '644'
is not the same as '0644'
a git module on a branch that moves

Find it.

Terminal window
ansible-playbook site.yml --diff | grep -B5 'changed:'

Fix. changed_when: false on read-only commands, creates:/removes: where possible, and quote file modes as '0644'. The mode one is worth emphasising: unquoted 0644 in YAML is the octal-looking integer 644, which is a different permission set, and it changes every run.

Symptom. Everything fails at the first become: true.

Cause. No passwordless sudo, and no password supplied.

Fix.

Terminal window
ansible-playbook site.yml --ask-become-pass # -K

Or configure passwordless sudo for the automation user, scoped:

# /etc/sudoers.d/ansible — validate with visudo -cf
deploy ALL=(ALL) NOPASSWD: ALL

And note chapter 4’s warning: any playbook that writes a sudoers file must use validate: 'visudo -cf %s', because a broken one locks out privilege escalation entirely, including Ansible’s.

Symptom. unreachable, not failed.

Causes, in order:

a. the host really is down
b. host key verification failed (new or changed key)
c. wrong ansible_user or no key on the target
d. a bastion is needed and is not configured
e. ansible_host is wrong or DNS is stale

Diagnose with the actual SSH command Ansible is running:

Terminal window
ansible-playbook site.yml -vvvv 2>&1 | grep -m1 'SSH: EXEC'

Then run that command by hand. This resolves the majority of connection problems in one step, because you see the real arguments rather than guessing at them.

For (d), the fix belongs in inventory rather than in someone’s ~/.ssh/config:

ansible_ssh_common_args: '-o ProxyJump=bastion.example.com'

Symptom. Every module fails on one host, or on a whole distribution.

Cause. No Python, or Ansible’s discovery picked a path that does not exist.

Fix. Bootstrap with raw, which needs no Python (chapter 3):

- hosts: new_machines
gather_facts: false
tasks:
- ansible.builtin.raw: |
test -e /usr/bin/python3 || \
(apt-get update && apt-get install -y python3-minimal)
changed_when: false
- ansible.builtin.setup:

Then pin ansible_python_interpreter in inventory so discovery cannot change its mind later.

Symptom.

fatal: [web1]: FAILED! => {"msg": "The conditional check
'app_config.stdout is defined' failed. The error was:
error while evaluating conditional: 'dict object' has no
attribute 'stdout'"}

Cause. A registered variable from a task that was skipped. The variable exists — with skipped: true — but has no stdout.

Fix.

when: app_config is not skipped and 'x' in app_config.stdout
# or
when: app_config.stdout | default('') is search('x')

The same error under --check usually means a command task skipped in check mode. Add check_mode: false to read-only commands (chapter 5).

Symptom. A task guarded by when: enable_feature runs even when the feature is off.

Cause. enable_feature is the string "false", not the boolean. Non-empty strings are true. This happens with -e enable_feature=false, with values from environment variables, and with quoted booleans in YAML.

Fix.

when: enable_feature | bool

Put | bool on every boolean that came from outside a plain YAML file. Chapter 7 has the full explanation; this is the single most common one-line bug in Ansible.

Symptom. --tags setup runs far more than the setup tasks, or nothing at all.

Cause. include_tasks with a tag on the include statement. The tag applies to the include, not to the tasks inside it — so once the include runs, everything inside runs unconditionally (chapter 8).

Fix.

- ansible.builtin.include_tasks:
file: setup.yml
apply:
tags: [setup]
tags: [setup]

Or use import_tasks, where tags propagate automatically. Check what you are about to run:

Terminal window
ansible-playbook site.yml --list-tasks --tags setup

11. It takes twenty minutes and it should not

Section titled “11. It takes twenty minutes and it should not”

Symptom. Slow, out of proportion to what it does.

Causes, in order of impact:

a. forks = 5 (the default)
b. pipelining off (the default)
c. fact gathering on every host, uncached
d. a loop over `command` or `package` that should
be one call with a list
e. ControlPersist too short for the gaps between
tasks

Find it before fixing it.

callbacks_enabled = ansible.posix.profile_tasks

Almost always one or two tasks are the whole runtime, and it is usually (c) or (d).

12. A file is world-readable and contains a password

Section titled “12. A file is world-readable and contains a password”

Symptom. Found in a review, or an audit, or worse.

Causes:

a. template/copy with no `mode:` — the default
follows the remote user's umask, often 0644
b. no_log missing, so the value is in a CI log
forever
c. a loop printed its items, passwords included
d. someone ran `ansible-vault decrypt` and
committed the result

Fix. Explicit mode: '0600' and owner: on anything with a secret, no_log: true on the task, loop_control: label: on the loop, and ansible-vault edit rather than decrypt (chapter 12).

And treat a secret that reached a CI log as compromised, not as something to delete. Rotate it.

Terminal window
# 1. What will run, and where?
ansible-playbook site.yml --list-hosts --list-tasks
# 2. What would it change?
ansible-playbook site.yml --check --diff --limit web1
# 3. What does this host actually believe?
ansible-inventory --host web1
ansible web1 -m ansible.builtin.setup
# 4. What is Ansible really doing?
ansible-playbook site.yml -vvv --limit web1
# 5. Step through it
ansible-playbook site.yml --step --limit web1
# 6. Resume from where it broke
ansible-playbook site.yml --start-at-task "Deploy the config"

Steps 1 and 2, on one host, answer most questions before anything is changed. And --start-at-task is the flag that turns “the playbook failed at task 40” from a fifteen-minute re-run into a fifteen-second one.

The retry file is worth knowing about too:

[defaults]
retry_files_enabled = true
retry_files_save_path = ~/.ansible-retry
Terminal window
ansible-playbook site.yml --limit @~/.ansible-retry/site.retry

That re-runs only the hosts that failed, which on a two-hundred-host run is the difference between a coffee and an afternoon.

  • A host pattern that matches nothing exits 0. Assert in CI that hosts were actually touched.
  • A variable that will not change is usually in a role’s vars/ instead of defaults/ — or was set by set_fact, or passed with -e, or lives in a group_vars file named after a group that does not exist.
  • A handler that never runs usually means the task reported ok — which is correct. The other causes are a failed host, a name mismatch, a play boundary, or expecting it to run early.
  • A second run with changes means a non-idempotent task. Watch for command without changed_when, self-matching lineinfile regexes, and unquoted file modes — 0644 is not '0644'.
  • -vvvv and then run the printed SSH: EXEC command by hand. It resolves most connection problems in one step.
  • Bootstrap missing Python with raw, then pin the interpreter.
  • “dict object has no attribute stdout” means the task was skipped. Guard with is not skipped, and add check_mode: false to read-only commands.
  • A when that is always true is a string. | bool.
  • Tags do not reach inside include_tasks without apply:. Check with --list-tasks --tags.
  • Slow is usually uncached facts or a loop that should be a list. profile_tasks finds it.
  • Set mode: '0600' and no_log: true on anything with a secret, and rotate anything that reached a log.
  • Debug in order: --list-tasks → --check --diff --limit → ansible-inventory --host → -vvv → --start-at-task. And --limit @...retry re-runs only what failed.

Next: the honest verdict — where Ansible is clearly right, where containers and Terraform win, and the two questions that decide it.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source