There’s a box at a remote site. It lives behind NAT, behind a router I don’t control, on a connection that hands out a new public IP whenever the ISP feels like it.

To reach it, I had a ritual. SSH into a bastion. From the bastion, SSH into the target. Type two passphrases, pray nothing changed, and hope the WAN IP I memorized last month was still the WAN IP.

ssh -J bastion target

That one flag hid a small empire of fragility.

The investigation

It broke on a Tuesday, the way these things do. ssh: connect to host ... port 22: Connection timed out.

First instinct: the target is down. It wasn’t — a colleague on-site confirmed it was humming along, recording, serving, fine.

Second instinct: the bastion is down. It wasn’t either. I could land on the bastion clean.

ssh bastion 'echo alive'
alive

So the bastion was up, the target was up, and the hop between them was dead. I jumped onto the bastion and tried the second leg by hand.

ssh me@198.51.100.x
ssh: connect to host 198.51.100.x port 22: No route to host

There it was. The remote site’s WAN IP had rotated overnight. The 198.51.100.x baked into my SSH config — and into the bastion’s known_hosts, and into a port-forward rule, and into my muscle memory — now pointed at some stranger’s DHCP lease.

The “aha”

I’d built a chain of three brittle links to solve one problem: the target has no stable address I can reach from outside.

Every fix I’d ever applied — dynamic DNS, a forwarded port, a second hop — was just another way of chasing an address that refused to hold still. The bastion wasn’t a solution. It was a workaround for a missing fact.

What if the box just had a stable address? One that didn’t care about NAT, ISP whims, or which coffee shop my laptop was sitting in?

   BEFORE                                AFTER
 ┌─────────┐                          ┌─────────┐
 │ laptop  │                          │ laptop  │
 └────┬────┘                          └────┬────┘
      │ ssh                                │ ssh
      ▼                                    │ (encrypted mesh)
 ┌─────────┐                               │
 │ bastion │  ◄── public IP that          │
 └────┬────┘      keeps rotating          │
      │ ssh                                ▼
      ▼                                ┌─────────┐
 ┌─────────┐   NAT / CGNAT             │ target  │
 │ target  │   no inbound port         │ 100.x.. │ stable tailnet IP
 └─────────┘                           └─────────┘

That address is exactly what a WireGuard-based mesh VPN hands you. Put the laptop and the target on the same tailnet, and each device gets a private IP that follows it everywhere — through NAT, through CGNAT, through IP rotations — because the mesh handles NAT traversal for you.

The fix

Install the mesh on both ends. (Generic Tailscale-style flow below.)

# On the target — behind NAT, no inbound ports needed
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --ssh --hostname remote-target

# On my laptop
tailscale up

# Confirm the target shows a stable tailnet IP
tailscale status
# 100.x.y.z   remote-target   linux   active; direct

Now SSH goes straight there. No bastion, no -J, no rotating address.

ssh me@100.x.y.z
# or, by MagicDNS name
ssh me@remote-target

Lock it down with an ACL so only my devices can reach port 22 on that box:

{
  "acls": [
    { "action": "accept", "src": ["tag:admin"], "dst": ["tag:remote:22"] }
  ]
}

Then I deleted the bastion’s forward rule, pulled the stale host key, and retired the jump chain entirely.

ssh-keygen -R 198.51.100.x
one tailnet, every device gets a stable addresslaptop100.64.0.2target100.64.0.7NAT / CGNATWireGuard tunnel · ACL-gated
The hop is gone. The tunnel goes straight through NAT, ACL-gated, end to end.

Why it happened

The bastion was never the point. It existed because the target had no reachable, durable address — so I borrowed one from a machine that did, then chained a second SSH session on top.

Every link in that chain depended on a public IP staying put. One of them rotated, and the whole thing collapsed. That’s not a bug in SSH. That’s the architecture telling you it was held together with assumptions.

A mesh VPN moves the addressing problem off the public internet entirely. The tailnet IP is yours, it’s stable, and it doesn’t care what the ISP does at 3 a.m.

Takeaways

  • If you’re chaining ssh -J to reach a NAT’d box, that’s a missing stable address, not a routing puzzle. Fix the address, delete the chain.
  • Mesh VPNs (WireGuard-based) give every device a durable private IP that survives NAT, CGNAT, and ISP rotations — no inbound ports to forward.
  • Stable addressing kills a whole class of failures: stale known_hosts, dead port-forwards, dynamic-DNS lag, memorized WAN IPs.
  • Gate it with ACLs. A flat tailnet where everything reaches everything is just a bigger blast radius. Tag, scope, restrict to port 22.
  • The most reliable hop is the one you deleted. Fewer moving parts, fewer 3 a.m. timeouts.