Contents

Tailscale SSH and the End of Bastion Hosts

The jump box was always a workaround for a problem we can now delete

Contents

The bastion host is such a fixture of network design that we stopped asking what it was for. One hardened machine with a public IP, port 22 open to the world, everything else tucked away behind it. You SSH to the bastion, then SSH onward. It is in every reference architecture and every homelab guide, mine included.

Its purpose is narrow: it exists because your internal machines have no route from the internet, and you needed exactly one that did. It is a workaround for the shape of IPv4 NAT. Everything else about it — the hardening, the audit logging, the ProxyJump config, the agent forwarding you know you should not enable — is machinery built on top of that one structural compromise.

Give every machine a routable, authenticated, encrypted address, and the bastion has no job. That is what a mesh overlay does, and Tailscale SSH is the piece that finishes the argument by moving authentication itself into the mesh.

What Tailscale SSH actually does

Advertisement

Ordinary Tailscale gives each machine a stable address on a private overlay, and you SSH across it exactly as before — sshd on the far end, keys, authorized_keys, the lot. The bastion is gone because everything is directly reachable, and that alone is most of the win. I covered that baseline in Tailscale, a zero-config mesh VPN.

Tailscale SSH goes further. The Tailscale daemon terminates the SSH connection itself. There is no sshd involved, no authorized_keys, no host key. Authentication is the WireGuard session — the far end already knows cryptographically which node you are and which identity is behind it, because that was established when your device joined the network. Authorisation is a rule in your ACL policy file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// Tailscale ACL policy
{
  "ssh": [
    {
      "action": "accept",
      "src":    ["autogroup:member"],
      "dst":    ["tag:homelab"],
      "users":  ["autogroup:nonroot", "admin"]
    },
    {
      // Re-prove identity in the browser before root
      "action":    "check",
      "checkPeriod": "12h",
      "src":       ["group:admins"],
      "dst":       ["tag:homelab"],
      "users":     ["root"]
    }
  ]
}

Enable it per machine:

1
sudo tailscale up --ssh

That is the whole setup. From another node: ssh admin@web01 works. No key exchange, no known_hosts prompt, no config.

The check action deserves attention because it has no clean equivalent in stock OpenSSH. It forces a browser re-authentication against your identity provider before the session opens — actual, current, interactive proof of identity, with the freshness window you specify. Getting that with plain SSH means a PAM module and a lot of hope.

Why this is better than a hardened jump box

The bastion’s fundamental problem is that it is a single machine, exposed to the entire internet, holding the keys to everything behind it. You spend enormous effort making that acceptable: fail2ban, certificate auth, aggressive sshd config, log shipping. All of it is compensating for the decision to put it there.

With the overlay, there is no listening port on the public internet. Port scans find nothing. The bots that hammer port 22 every second of every day have nothing to hammer. That is a category change rather than an improvement. The internet-facing attack surface is simply absent.

Access management moves too. Instead of key files scattered across machines, you have one policy file describing who reaches what as which user, versioned in Git, reviewable in a diff. Revocation is removing a device from the tailnet, and it takes effect in seconds across every node at once. Compare that with the manual sweep through authorized_keys files I complained about at length in SSH certificates beat authorized keys — this solves the same problem from the other end, by making the credential ephemeral rather than making its distribution smarter.

Session recording is available on higher tiers, writing to an S3-compatible target. If you have wanted an audit trail of what people actually typed on your machines and balked at building it, that is a real convenience — though it puts full session contents somewhere, which deserves its own thought.

The dependency you are taking on

Advertisement

Here is the part the marketing does not lead with, and it is the reason I would not do this on every machine.

The control plane is not yours. Tailscale’s coordination servers handle key distribution and policy. Data flows peer to peer, so your traffic stays off their infrastructure. Node authorisation and ACL evaluation still depend on it entirely. If their control plane is down, existing sessions survive and new key exchanges struggle. That is a dependency on someone else’s uptime for access to hardware sitting in your own flat.

Tailscale SSH deepens it considerably. Ordinary Tailscale is a network layer; if it fails, sshd is still listening on the LAN and you walk over to a keyboard or connect locally. Tailscale SSH replaces sshd as your authentication mechanism. If the daemon fails to start after an upgrade, or a policy file typo removes your own access, or the node’s key expires while you are away, you have no SSH at all — the fallback you assumed was there is the thing you turned off.

The mitigation is unglamorous and mandatory: keep sshd running and reachable on the LAN. Not exposed to the internet, just listening internally with a key you hold. It costs nothing and it is the difference between an inconvenience and a trip to wherever the machine physically lives. I run sshd bound to the LAN interface on every machine that also has Tailscale SSH, and I have needed it twice — both times because of my own ACL mistakes, which is exactly the failure the mitigation is for.

Key expiry is the sharp edge. Tailscale node keys expire — 180 days by default. An expired node drops off the tailnet and needs re-authentication from that machine, in a browser. On a headless server you have just discovered a beautiful chicken-and-egg problem: you need access to restore access. Disable key expiry on your infrastructure nodes in the admin console. Do it now, before you read the next paragraph, because everyone learns this the hard way and there is no reason for it to be you.

If the third-party dependency is the objection, Headscale is a self-hosted control plane that works well and puts the coordination back under your roof. Tailscale SSH support in Headscale has historically trailed the upstream feature, so check the current state before planning around it. Tailscale versus NetBird covers the broader landscape of alternatives.

Retiring the bastion without a bad evening

The migration is boring if you do it in the right order and miserable if you do not. The order:

One. Install Tailscale on your laptop and on every server, tag the servers, verify tailscale status shows them all. Change nothing else. Live with it for a week — you will find the one machine whose NAT situation forces a relay, and you want to find that now rather than during a cutover.

Two. Bind sshd to the LAN and the Tailscale interface, and remove the public listener from the bastion’s firewall. Do this while you still have physical or console access. Confirm you can reach every machine over the overlay, as yourself, with your normal keys.

Three. Delete the ProxyJump stanzas from your ~/.ssh/config. This is the moment the change becomes real, and it is also the moment you find the three scripts that hard-coded the bastion’s hostname. Fix them.

Four. Only now, if you want it, turn on --ssh and write ACLs. This is a separate decision from retiring the bastion, and conflating the two is how people end up debugging policy syntax at the same time as debugging routing.

Five. Leave the bastion powered on and reachable internally for a month before you delete the VM. It costs nothing and it is the cheapest insurance you will ever buy.

The step people skip is the second half of step two: actually removing the public port forward. It is astonishing how many “we moved to a mesh VPN” setups still have 22 open to the world on a box nobody logs into any more, quietly accumulating brute-force attempts against a machine nobody is watching. If you have gone to the trouble of building the overlay, close the port. That is the whole return on the work.

Practical configuration

Tag your machines, then write policy against tags rather than device names:

1
sudo tailscale up --ssh --advertise-tags=tag:homelab

Tagged devices are owned by the tailnet rather than a user, which means they do not inherit that user’s expiry or disappear when the account is removed. For servers this is always what you want.

A policy shape that has held up for me:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "tagOwners": {
    "tag:homelab": ["group:admins"],
    "tag:public":  ["group:admins"]
  },
  "ssh": [
    {
      "action": "accept",
      "src":    ["group:admins"],
      "dst":    ["tag:homelab"],
      "users":  ["autogroup:nonroot"]
    },
    {
      "action":      "check",
      "checkPeriod": "8h",
      "src":         ["group:admins"],
      "dst":         ["tag:public"],
      "users":       ["autogroup:nonroot", "root"]
    }
  ]
}

Internal machines get frictionless access as a normal user. Anything with a public role demands a fresh browser check. Root goes through check everywhere. autogroup:nonroot means “any user account except root”, which saves enumerating usernames.

Test policy changes before applying them. The admin console has a preview that tells you who can reach what, and the ACL file rejects syntax errors — but it will happily accept a semantically valid file that locks you out. This is where that LAN-bound sshd earns its keep.

There is one more piece worth wiring up while you are here. Tailscale can advertise subnet routes, which lets an overlay node act as a gateway to devices that will never run the client — the managed switch, the IPMI card, the printer that predates TLS.

1
2
3
# On one always-on machine, expose the management subnet
sudo tailscale up --ssh --advertise-tags=tag:homelab \
  --advertise-routes=192.168.1.0/24

Approve the route in the admin console, and every device on your tailnet can reach that subnet. This is the piece that lets you delete the bastion entirely rather than keeping it around as “the thing I use to get to the switch”. It also means one node becomes a router with a broad reach, so restrict who can use it with an ACL rather than leaving it open to every member.

Troubleshooting

ssh: connect to host web01 port 22: Connection refused — MagicDNS resolved the name but --ssh is not enabled on that node. tailscale status shows the node; check it was brought up with the flag.

Connection accepted, then immediately closed. No ACL rule matches. tailscale debug ssh-policy on the destination prints the evaluated policy from that machine’s point of view, which is far more useful than staring at the JSON.

Browser check never appears. The check action needs a browser on the client device. Over a plain terminal on a headless box it prints a URL and waits. On a machine with no browser and no way to open the URL elsewhere, you are stuck — do not use check rules for machine-to-machine access.

It worked yesterday, today the node is gone. Key expiry. See above. Disable it for servers.

Sluggish connections that were fast last week. You have fallen back to a DERP relay because direct connection failed. tailscale netcheck and tailscale status show whether you have a direct path. Usually a firewall or a change in the ISP’s NAT behaviour.

Permission denied as root despite a rule allowing it. autogroup:nonroot excludes root deliberately. Root needs its own explicit rule listing "root" in users.

Is it worth it?

For most homelabs: yes, and the bastion should go. The security improvement is genuine and the operational improvement is larger — every machine directly reachable from your laptop with no jump chain, no ProxyJump config, no forwarded agent to worry about.

Whether to use Tailscale SSH or ordinary Tailscale with sshd is the closer call, and it turns on how you feel about the dependency. My compromise: Tailscale for transport everywhere, Tailscale SSH on machines I access casually, and sshd with hardware-backed keys still listening on the LAN on everything. Belt and braces, and the braces have paid for themselves.

Skip it entirely if you have compliance requirements that need self-hosted identity, or if the idea of a third party’s outage affecting access to your own hardware is intolerable. Both are legitimate. Headscale exists for exactly that objection.

The bastion host was a good answer to a real constraint. The constraint went away, and the answer should follow it.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.