Ansible SSH Connection Failed: Fix It Fast
Rerun with -vvv to reveal Ansible's real ssh command, fix host keys, user, and key path, then add ProxyJump for bastion hosts..
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- ✓An Ansible control node with SSH client installed
- ✓Inventory access to one failing host plus its bastion if any
- ✓Basic comfort with ssh, keys, and known_hosts handling
- Rerun with ansible -m ping -vvv: it prints the exact ssh command Ansible runs, so you can replay and debug it directly
- Host key failures mean known_hosts disagrees: clear the stale key with ssh-keygen -R, or set host_key_checking for ephemeral fleets
- Wrong user or key path is the top cause: set ansible_user and ansible_ssh_private_key_file, and keep key perms at 600
- Bastion-only hosts need ansible_ssh_common_args with ProxyJump; agent forwarding covers multi-hop without copying keys
Ansible is a manager who phones each server and reads instructions aloud. Failed to connect via ssh means the call never went through — wrong number (host key changed), wrong extension (user), no ID badge (key file), or a locked lobby door (bastion). You don't rewrite the instructions. You fix the phone call: dial the exact number Ansible dialed, hear where it fails, and correct that one step.
You write a clean playbook, point it at a fresh host, and get fatal: [host]: UNREACHABLE! => {"msg": "Failed to connect to the host via ssh"}. The playbook is fine — Ansible never got far enough to read it. SSH died first, and the JSON blob hides whether the failure was host keys, the wrong user, a bad key path, or a bastion you forgot to hop through.
The mistake most teams make is debugging the playbook: reordering tasks, tweaking variables, re-running the same failing command. Nothing in the play will help, because the connection layer below it is broken. Ansible shells out to OpenSSH, so every UNREACHABLE is an ssh failure wearing JSON clothes.
This guide stays at the connection layer until it works: replay Ansible's real ssh command with -vvv, fix host keys, user, key path, and agent forwarding in order, add ProxyJump for private subnets, and learn why interpreter-discovery warnings are a different error you should stop chasing here. Every step includes the exact command to run and what its output proves.
Replay Ansible's Real SSH Command with -vvv
Ansible doesn't have its own SSH — it assembles an OpenSSH command line from your inventory and runs it. The -vvv flag makes Ansible print that exact command, and that line is the entire debugger: copy it, paste it into your shell, and iterate there instead of re-running playbooks. You'll immediately see the user (-o User=), the key file (-i), the port (-p), and every -o option Ansible derived from ansible_ssh_common_args and ansible.cfg. Most UNREACHABLE mysteries dissolve the moment you read that line.
Run the smallest possible reproduction first: ansible -i inventory problem-host -m ping -vvv. The ping module needs nothing but a working connection, so its failure is pure transport. Read the ssh debug from the bottom up — the last lines name the phase: Connection timed out is network or security groups, Host key verification failed is known_hosts, Permission denied (publickey) is user or key, and command not found would be a broken ssh install.
Keep this replay loop as your habit for every connection bug. Fix the pasted ssh command until ssh target 'echo ok' succeeds by hand, then run the Ansible command unchanged — it now works, because it runs the command you just fixed. Debugging anywhere else first (tasks, roles, variables) burns time on layers that never executed.
Host Key Checking: Stale known_hosts Entries
OpenSSH trusts a host exactly once: on first contact it records the host key in ~/.ssh/known_hosts, and on every later contact it aborts if the key differs. That protects you from hijacks — and breaks every workflow that recycles IPs. Rebuilt instances, reimaged dev boxes, and autoscaled fleets all present new keys for old addresses, so Ansible halts with Host key verification failed while the host itself is perfectly healthy. Fresh hosts fail the other way: the key is simply unknown and the prompt to accept it hangs non-interactive runs.
Confirm staleness directly. ssh-keygen -F <ip-or-host> shows the recorded entry; a WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED banner on manual ssh proves the mismatch. The surgical fix is ssh-keygen -R to drop just that entry, followed by ssh-keyscan to record the fresh key — never delete the whole known_hosts file, which distrusts every host you own.
Choose the policy per environment deliberately. For pets (long-lived prod hosts), keep strict checking and manage known_hosts like code, refreshed at provision time. For cattle (ephemeral CI and autoscaled fleets), set ANSIBLE_HOST_KEY_CHECKING=False or host_key_checking = False in ansible.cfg so recycled IPs flow. Document which environments relax checking, because the relaxed ones trade hijack protection for automation smoothness.
Wrong User, Wrong Key Path, Wrong Permissions
Permission denied (publickey) is Ansible's most common UNREACHABLE, and it's almost always identity, not network. Cloud images accept different default users — ubuntu for Ubuntu, ec2-user for Amazon Linux, admin for Debian, azureuser on Azure — so the right key with the wrong ansible_user still fails. Custom AMIs and hardened images narrow it further. When manual ssh works but Ansible fails, the user is the first field to diff, because humans type the right user from memory while the inventory still holds the old one.
The key path is the second suspect. ansible_ssh_private_key_file must point at a file that exists on the control node (relative paths resolve from the playbook's directory, which breaks when CI runs elsewhere — prefer absolute paths or ~/.ssh/). The key must be mode 600: OpenSSH refuses group- or world-readable private keys with an UNPROTECTED PRIVATE KEY FILE error that Ansible surfaces as UNREACHABLE. A passphrase-protected key with no ssh-agent running hangs or fails the same way.
Verify the triple mechanically: grep the effective user and key file with ansible-inventory --host, check existence and mode with ls -l, then ssh -i <key> <user>@<host> by hand. If the hand command works, Ansible works — the inventory just wasn't saying what you thought it said.
Agent Forwarding Without Copying Keys Around
Multi-hop setups fail when engineers solve the second hop by copying private keys onto the bastion — which spreads key material to a shared box and still breaks when that copy drifts. The cleaner mechanism is agent forwarding: your local ssh-agent holds the decrypted key, the -A flag (ForwardAgent yes) lets the bastion ask your laptop to sign the second-hop challenge, and no private bytes ever leave your machine. When forwarding is off, the bastion-to-target leg has no credentials and dies with publickey denied, even though both individual legs look fine.
Verify the chain in layers. ssh-add -L on your machine must list the key — an empty agent forwards nothing, so add it with ssh-add first. Then ssh -A bastion and, from inside, ssh target: success proves the agent rode along. ssh -v on the second leg shows whether the agent channel (auth socket) arrived; its absence means the client config or the bastion's AllowAgentForwarding no disabled it.
In Ansible, keep private keys off middle boxes entirely: the control node's agent plus ProxyJump handles auth end to end. Reserve ProxyCommand tricks for ancient bastions, and audit bastions for stray copied keys — every copy is a revocation headache waiting for an offboarding.
ProxyJump and Bastions for Private Subnets
Hosts in private subnets have no route from your control node — direct ssh times out regardless of keys, and no inventory tweak fixes a missing network path. The standard answer is a bastion (jump host) with a public address that reaches both you and the targets. OpenSSH's ProxyJump (-J) Atlas Shrugged this into one flag: ssh -J user@bastion user@target opens the bastion leg, tunnels the target leg through it, and authenticates both, with a single command to debug.
Prove the path by hand before encoding it. ssh -J ops@bastion ubuntu@10.0.4.21 'echo ok' exercises both legs and both authentications at once; its failure tail tells you which leg broke. Then encode the winner in Ansible with ansible_ssh_common_args: '-o ProxyJump=ops@bastion.example.com' on the private group — group scope matters, because applying it fleet-wide routes public hosts through a needless hop.
Check the security groups as part of the same fix: the bastion must accept port 22 from your control node, and targets must accept 22 from the bastion's security group (not from the world). For fleets, prefer ssh config ProxyJump stanzas or inventory group vars over per-host args, and consider SSM Session Manager later if bastion upkeep grows teeth.
Interpreter Discovery Is a Different Error Entirely
Once SSH works, Ansible may warn about Python: Using discovered interpreter /usr/bin/python3, or fail with /usr/bin/python not found. These are post-connection errors — the transport succeeded and the module bootstrap stumbled. Teams that spent an hour fixing UNREACHABLE often keep editing interpreter settings for a connection failure, or vice versa: chasing sshd for what is really a missing Python. The dividing line is crisp. UNREACHABLE means ssh died; anything mentioning interpreters, python, or module helpers means ssh lived.
Read which side you're on from the task output. fatal UNREACHABLE with Failed to connect via ssh is transport: stay in this guide's first five sections. A failed (not unreachable) task naming /usr/bin/python, or a warning followed by success, is interpreter land: set ansible_python_interpreter=/usr/bin/python3 for the group, or install Python on minimal images. Mixing the two sends you editing inventory Python paths while the firewall blocks port 22.
Make the pipeline teach the difference. A pre-flight ansible -m ping that must pass before any play runs separates transport from everything else: ping green plus play red means look at Python and modules, while ping red means stay at ssh. That one gate stops the most common misdiagnosis in Ansible outages.
A Rebuilt Fleet Recycled IPs and Host Keys Killed Deploys for 40 Minutes
- Recycled IPs plus strict host keys equal fleet-wide UNREACHABLE. Any autoscaling or rebuild workflow must refresh known_hosts as part of provisioning, not as incident response.
- When manual ssh works but Ansible fails, diff their options first — -vvv prints Ansible's exact command, and one flag (like key checking) is usually the whole gap.
- Rebuilding hosts to fix a connection error can spread it. Diagnose the control node's state before churning the fleet.
| File | Command / Code | Purpose |
|---|---|---|
| replay-ansible-ssh.sh | ansible -i inventory problem-host -m ping -vvv 2>&1 | grep -E '^<.*ssh' | Replay Ansible's Real SSH Command with -vvv |
| host-key-triage.sh | ssh-keygen -F 203.0.113.44 | Host Key Checking |
| identity-triage.sh | ansible-inventory -i inventory --host problem-host | grep -E 'ansible_user|ansib... | Wrong User, Wrong Key Path, Wrong Permissions |
| agent-forwarding.sh | ssh-add -L | Agent Forwarding Without Copying Keys Around |
| inventory-private-subnet.yml | all: | ProxyJump and Bastions for Private Subnets |
| which-error-is-it.sh | ansible -i inventory problem-host -m ping | Interpreter Discovery Is a Different Error Entirely |
Key takeaways
Common mistakes to avoid
5 patternsDebugging tasks and roles for a transport failure
Deleting the whole known_hosts file
Copying private keys onto the bastion
Chasing Python interpreters for an ssh failure
Applying ProxyJump fleet-wide
Interview Questions on This Topic
Ansible reports UNREACHABLE: Failed to connect via ssh. What's your first move?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Ansible. Mark it forged?
5 min read · try the examples if you haven't