SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Ansible: ignore unreachable hosts

Ansible treats an unreachable host differently from a failed task. Use ignore_unreachable, serial and max_fail_percentage, and still see what was missed.

An unreachable host is not a failed task

To ignore unreachable hosts in Ansible you set ignore_unreachable: true, and the switch works. The part that matters is knowing when to use it, because Ansible handles two different problems in two different ways. A task that ran on the host and returned an error is a failure. A host Ansible could not connect to at all is unreachable. ignore_errors covers the first case only. ignore_unreachable covers the second case only.

Here is the difference in a play recap.

PLAY RECAP *********************************************************************
web1  : ok=7  changed=2  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
web2  : ok=0  changed=0  unreachable=1  failed=0  skipped=0  rescued=0  ignored=0

Ansible connected to web1 and ran seven tasks. web2 shows unreachable=1 and failed=0, which means nothing ran on it at all. Ansible never got a connection, so it removed the host from the play and carried on with the rest. If that play was installing a security update, one of your servers does not have it.

What makes a host unreachable

Unreachable means the connection failed before any module reached the host. There is no module output to read, only a connection error, and it appears at the first task that touches the machine.

fatal: [web2]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ssh: connect to host 203.0.113.20 port 22: Connection refused", "unreachable": true}

The msg field carries the real cause. These are the ones you will meet:

  • Connection refused: the TCP connection was rejected, so nothing is listening on that port. sshd is stopped, or SSH moved to another port and your inventory still says 22.
  • Connection timed out: nothing answered at all. A firewall is dropping the packets, or the server is off. Each attempt costs the full connection timeout, which is 10 seconds by default.
  • Host key verification failed.: the key in ~/.ssh/known_hosts does not match the key the server presented. A rebuilt VPS keeps its IP address and gets a new host key, so this is expected after a reinstall and serious at any other time.
  • Permission denied (publickey): SSH answered and rejected your key. The port is fine, so this is authentication, usually the wrong ansible_user or a key that is not loaded.
  • Timeout (12s) waiting for privilege escalation prompt: the connection worked and become did not. sudo is waiting for a password that never arrives.

A missing Python interpreter is the cause people expect on that list, and it does not belong there. SSH connects, so the host is reachable. The module then has nothing to run in:

fatal: [db1]: FAILED! => {"changed": false, "module_stdout": "/bin/sh: 1: /usr/bin/python3: not found\r\n", "msg": "The module failed to execute correctly, you probably need to set the interpreter", "rc": 127}

That line says FAILED! and the recap counts it under failed, so ignore_unreachable will never touch it. Set ansible_python_interpreter for that host, or install python3 on it.

How to ignore unreachable hosts in a play

At task level, the keyword sits beside the module:

- name: Read the package list, and do not stop if the host is down
  ansible.builtin.command: dpkg -l
  register: packages
  changed_when: false
  ignore_unreachable: true

At play level it sets the default for every task in the play, and a single task can set it back:

- name: Opportunistic fleet maintenance
  hosts: all
  ignore_unreachable: true
  tasks:
    - name: This runs, cannot connect, and the play carries on
      ansible.builtin.ping:

    - name: This one still ends the play for a host that is down
      ansible.builtin.ping:
      ignore_unreachable: false

What changes underneath is worth knowing. With ignore_unreachable set, the host is no longer removed from the play, so every later task tries to connect again and fails again in the same way. Each of those attempts waits out the connection timeout, 10 seconds unless you change timeout in ansible.cfg. A twenty task play against one dead server adds about 200 seconds to the run and twenty red lines to the log.

So check once, then stop that host cleanly:

- name: Opportunistic fleet maintenance
  hosts: all
  gather_facts: false
  tasks:
    - name: Check that the host answers before doing any work
      ansible.builtin.ping:
      register: reachable
      ignore_unreachable: true

    - name: End the play for this host if it never answered
      ansible.builtin.meta: end_host
      when: reachable.unreachable | default(false)

    - name: Gather facts now that the connection is known good
      ansible.builtin.setup:

    - name: Refresh the package index
      ansible.builtin.apt:
        update_cache: true
      become: true

That is one connection attempt per dead host instead of one per task. end_host, added in Ansible 2.8, ends the play for the current host without marking it failed. The unreachable key exists on the registered result only when the connection failed, so default(false) keeps the condition valid on every host that answered. Fact gathering is off at play level because the implicit Gathering Facts task would otherwise be the task that meets the broken connection, and you want that to be your own ping.

ignore_unreachable is a play keyword and a task keyword. Keep it in the playbook where a reader can see it rather than inside a role, because it decides which hosts a run is allowed to miss. The split between playbooks and roles covers which layer should own a setting like this.

Why ignore_errors is the wrong tool here

The Ansible documentation is blunt about the limit. ignore_errors "only works when the task can run and returns a value of 'failed'. It does not make Ansible ignore undefined variable errors, connection failures, execution issues (for example, missing packages), or syntax errors."

A connection failure never becomes a task result with failed: true. It arrives as a separate flag, and Ansible acts on that flag first: the host goes onto the unreachable list and comes out of the play. Put ignore_errors: true on all twelve tasks of a play and a host with a closed SSH port still stops at the first one. This is the most common confusion in this area, and it is worth grepping your older playbooks for, especially ones written while learning to write a first playbook against a VPS.

Debug before you suppress

Suppression that becomes permanent is how a fleet drifts, because the host nobody can reach is also the host nobody is patching. Work through this order first. Every command here only reads.

  1. ansible web2 -i inventory.ini -m ansible.builtin.ping -o runs one module against one host and prints one line.
  2. Add -vvvv to that same command. Ansible prints the full ssh command it builds, including the target user, the port, the private key and the options it passes.
  3. Run that ssh command yourself with -v. If plain ssh cannot get in, the problem sits below Ansible and no playbook keyword will fix it.
  4. Read the msg string and match it against the list above. Connection refused and Connection timed out point at two different places, one at the SSH service and one at the network path.
  5. For Host key verification failed., look at what you have stored with ssh-keygen -F web2.example.com. If the server was rebuilt, drop the old entry with ssh-keygen -R web2.example.com and accept the new key after checking it against the provider console. Setting host_key_checking = False in ansible.cfg clears the error and also removes the check that would tell you a different machine is now answering on that address.
  6. For Permission denied (publickey), confirm what Ansible thinks it should use. ansible-inventory -i inventory.ini --host web2 prints the variables in effect, including ansible_user and ansible_port.
  7. If SSH works but modules do not, check the interpreter with ansible web2 -m ansible.builtin.raw -a 'command -v python3 || echo none'. The raw module runs a command through the shell and needs no python on the target.

Only after that does ignoring the host become a decision instead of a habit.

The recap counts unreachable separately, and CI usually misses it

ansible-playbook exits 0 on success, 2 when at least one host failed and 4 when at least one host was unreachable. Those two values are bit flags in the source, so a run with a failed host and an unreachable host exits 6. The ansible command returns the same codes. These were checked against the ansible-core source in August 2026.

Now set ignore_unreachable: true and run the same seven task play against the same dead host:

PLAY RECAP *********************************************************************
web1  : ok=7  changed=2  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
web2  : ok=7  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=7

web2 reports unreachable=0 and seven tasks ok, and the run exits 0. When the keyword is set, Ansible increments the ok and ignored counters for that host instead of the counter it calls dark, which is the one that fills the unreachable column. The red UNREACHABLE! lines are still printed, so the log is honest while the recap and the exit code are not.

A CI job that runs the playbook and checks only $? calls that run green, and nothing in its summary says a machine was never touched. Make the reachability check its own step, before the play:

ansible all -i inventory.ini -m ansible.builtin.ping -o

That prints one line per host and exits 4 if any host is unreachable, which gives the pipeline something to fail on and gives you the names in the log. ping needs a working Python interpreter on the target, so it proves a little more than the connection, which is usually what you want. Then run the playbook with ignore_unreachable so the hosts that are up still get their change.

any_errors_fatal and max_fail_percentage across a batch

These two play keywords decide what happens after something goes wrong on part of the fleet, and they treat unreachable hosts differently from each other.

any_errors_fatal: true does react to an unreachable host. Ansible finishes the current task on the rest of the batch, then stops the play for every host in it. Use it when a run only makes sense as all or nothing, such as a coordinated schema change.

max_fail_percentage: 30 does not react to an unreachable host. The check divides the number of failed hosts by the size of the batch, and unreachable hosts are held in a separate list, so they never move that number. Ten hosts with four unreachable keep going under max_fail_percentage: 10, while two hosts failing a task stop the play. The documentation adds one more trap: "The percentage set must be exceeded, not equaled." With serial: 4, stopping after two failures out of four means writing 49, not 50.

There is one case where unreachable hosts stop a run on their own. If every host in the batch is failed or unreachable, Ansible has nothing left to work with and ends the play with NO MORE HOSTS LEFT.

serial: rolling a change across the fleet

- name: Rolling nginx config update
  hosts: webservers
  serial: 2
  max_fail_percentage: 25
  tasks:
    - name: Deploy the site config
      ansible.builtin.template:
        src: site.conf.j2
        dest: /etc/nginx/conf.d/site.conf
        owner: root
        mode: "0644"
      become: true
      notify: Reload nginx
  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
      become: true

serial: 2 runs the whole play against two hosts, finishes it, then starts the next two. serial: "25%" scales with the size of the group. A list, serial: [1, 5, 10], is the canary shape: one host first, then five, then ten, with any hosts left over running in batches of the last size. max_fail_percentage is measured per batch, so the two work together. Break the first machine and the run stops before it breaks forty. That is what makes managing a fleet of Linux servers from one control machine safe to do from a single command.

When to ignore unreachable hosts, and when not to

Ignore them for opportunistic work. A fact collection run or an hourly drift check loses nothing by skipping a host that is down, because the next pass picks it up. Play level ignore_unreachable: true is the right answer there, paired with the ping step so the skipped names land somewhere a person will read.

Never ignore them for a security patch run. The value of that run is the guarantee that every host has the fix, and suppressing the unreachable state turns "one server is still vulnerable" into a clean green recap. The host that has been unreachable for two weeks is the host most likely to be far behind. Let that run exit 4 and let a person look at it.

One rule holds in both cases: suppress the stop, never the record. If a host was skipped, something has to say so, in the recap, in the CI log or in a monitoring alert. Ansible only knows a host exists during the seconds a play runs against it, so it is a poor place to learn that a server has been down since Tuesday. That job belongs to monitoring, and an Ansible playbook that installs Zabbix gets a fleet wide view running in an afternoon.

FAQ

What is the difference between ignore_errors and ignore_unreachable in Ansible?

ignore_errors: true applies to a task that ran on the host and returned a failure, such as a command exiting non-zero. ignore_unreachable: true applies to a host Ansible could not connect to, where no module ever ran. They read different fields on the task result, and neither covers the other case. The Ansible documentation states that ignore_errors "does not make Ansible ignore undefined variable errors, connection failures, execution issues (for example, missing packages), or syntax errors", and a closed SSH port is a connection failure.

Does ignore_unreachable hide the host from the play recap?

In effect, yes. With the keyword set, Ansible stops counting that host under unreachable and counts it as ok and ignored once per task, and the run then exits 0. The fatal: [host]: UNREACHABLE! lines still print, so the log is accurate even though the recap and the exit code are not. Watch the ignored column, or run ansible all -m ansible.builtin.ping -o as a separate step so an unreachable host still produces a non-zero exit code somewhere.

What exit code does ansible-playbook return when a host is unreachable?

It returns 4. A run with at least one failed host returns 2, and the two values are bit flags, so a run with both a failure and an unreachable host returns 6. A clean run returns 0. These codes were checked against the ansible-core source in August 2026. Setting ignore_unreachable: true removes the 4, which is why a pipeline that tests only the exit code cannot see a skipped machine.

How do I skip the rest of a play for a host that never answered?

Make the first task ansible.builtin.ping with ignore_unreachable: true and register: reachable, then follow it with ansible.builtin.meta: end_host under the condition when: reachable.unreachable | default(false). end_host ends the play for that host without marking it failed. Set gather_facts: false on the play so your ping is the task that meets the broken connection. Without this pattern the dead host stays in the play, and every later task waits out the connection timeout again.

Should I ignore unreachable hosts during a security patch run?

No. A patch run is worth doing because it gives you a guarantee that every host has the update, and ignoring unreachable hosts replaces that guarantee with a green recap. Let the run exit 4, read the names of the hosts that did not answer, and fix them. Suppression belongs to repeated opportunistic runs where the next pass will catch whatever was missed.

#ansible#playbooks#error-handling#inventory#automation