
A bastion host and Tailscale SSH solve remote access with different trust and operating models. A bastion centralizes ingress through a hardened jump server; Tailscale builds identity-aware connectivity over a tailnet and can remove the need to expose SSH publicly. This article explains the authentication and key-management differences, the operational trade-offs, and the cases where retaining a bastion or standard OpenSSH remains appropriate.

Before I could judge whether my bastion deserved its place, I went back to first principles: what does SSH actually need in order to be secure? Three things, and only one of them explains why jump servers exist at all:
An SSH bastion — also called a jump host or jump box — is the single controlled point of access to the internal network, and all SSH traffic gets funneled through it. That concentration is the entire pitch:
Those are real benefits, and they are why the pattern became standard practice. The question I had to answer honestly was what it costs to keep them.
A properly kept bastion is a standalone, hardened server, and "hardened" boils down to a concrete checklist:
The last item hides the model's soft spot. Agent forwarding keeps your private key on your laptop, which feels safe. But the forwarded socket on the bastion can potentially be used by any process running there, so the real rule becomes: the bastion must itself be trustworthy. What I had actually signed up for was not "my key never leaves my machine" but "I fully trust the most internet-exposed box I own." I defended that box against the whole internet every month, and only later did I ask why it deserved that much trust.
That last bullet bothered me most. Key hygiene was supposedly part of the point, yet the gateway never touches it; my monthly rotation ritual was pure overhead the bastion could not absorb. Add it all up, and the bastion sells you network access and central logs while billing you in failure risk, latency, and maintenance hours. The question the rest of this article answers: does Tailscale SSH keep the first part and drop the bill?

Enabling it is a one-line job: sudo tailscale up --ssh on a Linux server you're bringing onto the tailnet, or sudo tailscale set --ssh on a machine that's already running Tailscale. That single flag packs in more work than you'd guess. Behind the scenes, tailscale set --ssh performs three main tasks:
tailscaled gets configured to intercept all tailnet traffic routed to port 22 on the device's Tailscale IP.One warning from the official docs deserves extra emphasis: flipping this flag will hang any existing SSH connections to the host's Tailscale IP. Don't enable it from inside a session you can't afford to lose. Plan the first switch the same way you'd plan a firewall change.
From that moment, Tailscale claims port 22 for the device's Tailscale IP address only — traffic arriving from your tailnet — and routes it to an SSH server run by Tailscale, not your standard sshd. Three mechanics make this work without installing anything new on the client:
netstack port interception catches traffic arriving over the tailnet and hands it to Tailscale's own SSH server.known_hosts configuration on the client means plain ssh myhost works out of the box — no new binary, no config file to edit.scp and sftp working with newer SSH clients, which require SFTP rather than the legacy SCP protocol.The detail that stands out to me most: your /etc/ssh/sshd_config and ~/.ssh/authorized_keys files are left completely untouched. Any non-Tailscale SSH connection to the same host keeps working exactly as before, which makes this one of the least invasive access changes I've seen. You can verify the result with tailscale status, which shows SSH as an advertised capability on the node.
Three friction points deserve a place in your notes before you commit:
dst rules. If you run SSH on a non-standard port today, that workflow stays outside Tailscale SSH.known_hosts matching is hostname-based. The custom known_hosts file works with hostnames, so a DNS resolution failure produces an error rather than a fallback. A flaky resolver can break access that should otherwise work.tailscaled — during an upgrade, for example — stops any existing Tailscale SSH session. If you automate package updates, expect to reconnect afterward.That's the plumbing sorted. The more interesting question is what happened to all the keys I used to rotate by hand every month — because the answer is "nothing," and that's exactly the point.

To appreciate what disappears with Tailscale SSH, I find it helps to be honest about what the old model was actually doing. In the traditional setup, I generate a key pair on the client, keep the private key on local disk, and distribute the public key into authorized_keys on every server. The server's only job during the handshake is to check that the connecting party can prove possession of the matching private key. The detail most people gloss over is where access control really lives: the local OS user must be able to read the key pair from disk, and that file permission — nothing else — is what stops the other accounts on my laptop from SSH-ing into anything. My entire boundary was a read bit on a file.
Tailscale SSH inverts that arrangement, and the inversion shows up in several places at once.
The tailnet connection is already authenticated and encrypted over WireGuard using Tailscale node keys, issued and managed by the control plane and tied to my identity in the tailnet's identity provider. The SSH client and server still negotiate a normal, fully encrypted SSH session — the protocol itself is untouched. What changes is the authentication phase inside it: the Tailscale SSH server already knows who the remote party is, takes over, and completes authentication with the SSH authentication type none. No further proof is requested from the client, because none is needed.
Authentication and authorization become cleanly separated concerns:
Here is the consequence I had to sit with: because no local SSH key pair gates access, any OS user on a client machine can initiate an SSH connection over Tailscale. Someone logged into a throwaway account on my laptop can attempt ssh prod-web just as easily as I can. The real boundary is the tailnet ACL, enforced server-side — a connection only succeeds if the policy permits it, on every attempt, regardless of who is typing.
That sounds alarming right up until you compare it to what it replaces: a read bit on a file in ~/.ssh, on a machine I patch on a best-effort schedule. And the payoff is the quiet death of the classic SSH key problems — keys left behind on forgotten machines, keys that never get rotated because rotation is tedious, keys copied onto personal devices. There is nothing to rotate, because nothing was ever distributed.

That "authenticity of host" prompt is the moment where traditional SSH quietly hands its hardest problem to the user. You get a hex fingerprint, you are expected to verify it out-of-band, and — if I am honest about how most of us behave — you type yes and move on. This pattern is called trust-on-first-use (TOFU), and when I look at how Tailscale SSH is designed, the interesting part is that it doesn't try to improve the prompt. It removes the need for the prompt entirely, by making the control plane do the key distribution that your eyeballs were never going to do properly.
The architecture rests on two distinct kinds of keys, and separating them makes the rest of the design click into place:
The trust anchor therefore shifts from "whatever fingerprint I saw on first connect" to "what the control plane vouches for."
The sequence is worth spelling out, because your access control policy does real work at each step:
Contrast that with plain OpenSSH: the first connection shows you a host key you are expected to verify (most clients save it afterward), and a mismatched key later means another server intercepted your connection. Tailscale inverts the model. It distributes public key information to all permitted clients up front, which guarantees that if you successfully connect to a Tailscale IP address, you have reached the correct device and not an imposter. Verifying the host key yourself is still best practice, but man-in-the-middle interception is ruled out structurally by the mesh rather than delegated to your attention span.
Rotation used to be the part of the monthly bastion ritual I dreaded most. Here it is a one-liner: tailscale up --ssh --force-reauth generates a fresh node key pair, stores the private key locally, and shares the public key with Tailscale for redistribution across the tailnet. If a key is actually compromised, the recovery path is straightforward: remove the keys on the device, re-install Tailscale, and re-authenticate — one pass generates and distributes fresh node and SSH host keys. No known_hosts surgery, no fleet-wide manual updates, no hope that you didn't break the one machine guarding everything else.

Of everything my bastion ritual demanded, key rotation was the chore that never improved. Patching the jump server took twenty minutes; re-issuing keys across a fleet — generating pairs, distributing the public halves, retiring the old ones — was pure administrative overhead. Tailscale SSH deletes that entire category of work, and it does so by changing what a key fundamentally is.
With Tailscale SSH, there is no manual generation step and no distribution step. Keys are created automatically as WireGuard keys, and each device connects using a public node key managed by the control plane. These keys expire when a session ends, and the node key itself expires automatically after a set period — which means rotation happens by default, not through discipline.
When I map this against my old bastion routine, the difference is structural, not incremental. Rotation is not a task I perform; it is a property of the system.
There is one caveat worth knowing before it bites you. The default key expiry of every 6 months can cause a sudden disconnection on long-running machines — for example, a VPS acting as an Exit Node that has been quietly forwarding traffic for months. The documented fix is to disable Key Expiry for that specific machine in the admin console. I read this as an explicit trade: you give up automatic rotation on that single node in exchange for uptime. For infrastructure that should never silently drop, that is a reasonable deal — but you want to make it deliberately, not discover it during an outage.
Expiry handles rotation, but the control plane also owns revocation, and this is where the comparison stops being close.
In the bastion world, none of this existed natively. Key rotation could not be solved without bolting on additional administrative controls, and revocation meant hunting down every authorized_keys file by hand. When I line the two models up side by side, it becomes clear the jump host has lost its strongest argument: it demanded the most labor exactly where Tailscale requires none.

Authentication was only half of the picture. The question of who may log in where, and as whom no longer lives in authorized_keys files scattered across your fleet — it moves into the ssh section of your ACL policy. That block sits independently of your network-level acls, which means you can grant someone reachability without handing them a shell, and revoke a shell without touching routing.
action: accept grants access immediately. check forces the user to re-authenticate through your identity provider before the session opens.src: the users or groups allowed to initiate connections.dst: target machines, matched by tags or hostnames. Port 22 is implicit and cannot be customized — these rules govern Tailscale SSH itself.users: the Linux usernames the source may log in as — root, deploy, or autogroup:nonroot, which permits every account on the host except root.checkPeriod: required when action is check. Values run from 1 minute to 168 hours, defaulting to 12 hours; "always" demands re-authentication on every single connection.recorder: a list of tags identifying your tsrecorder nodes. It takes tags, not user email addresses — a detail that trips people up on their first attempt.A policy I would actually deploy mixes tiers rather than treating everyone the same:
root and deploy on tag:production, with recorder: ["tag:recorder"] so every session is captured.autogroup:nonroot on tag:staging — enough freedom to work, not enough to wreck a box.action: check with checkPeriod: 4h.check mode is step-up authentication in practiceWhen a user runs ssh root@host under a check rule, Tailscale pauses and opens a browser-based re-authentication prompt against your identity provider. Once they sign in, the connection proceeds — and they stay exempt from further checks until the checkPeriod elapses. It feels like the step-up prompts you get when changing sensitive account settings, except it guards your servers.
Two warnings worth keeping in view:
"always" breaks automation. Tools like Ansible open many SSH connections in rapid succession, and forcing re-auth on each one produces behavior you did not ask for and will not enjoy debugging.check applies only to Tailscale SSH connections. Plain SSH or any other TCP traffic crossing your tailnet is never gated by it.This is the part that replaces the log-review ritual I used to perform on my jump server. Sessions stream to a tsrecorder node inside your tailnet, end-to-end encrypted, and are stored in asciinema format as newline-delimited JSON files. You can search them and replay them through the recorder node's web UI, or through the asciinema CLI if you prefer staying in a terminal.
One caution: terminal displays capture everything rendered on screen, secrets included. Decide deliberately what should ever pass in front of the recorder.
Stack the three pieces together — the approval gate, identity verification, and session logging — and what you get is lightweight just-in-time access for SSH, without bolting on external tooling.

Most of this article has been about why my bastion was more risk than reward, so this is the part where I force myself to be honest about the walls I hit. The first limitation is blunt: the Tailscale SSH server — the component that accepts connections on the destination — only runs on Linux, plus macOS with the open-source tailscale + tailscaled CLI variant. The standard Mac App Store client cannot host it, which surprises a lot of people on their first attempt. You also need Tailscale v1.24 or later on the server side. Connecting from a client, by contrast, works from any device on any platform — the asymmetry sits entirely on the destination.
A few common destinations are simply out of scope:
Platform support is the easy half. The deeper limits come from the model itself. Because Tailscale SSH authorizes by tailnet identity rather than by the client's OS user, it is a strong fit for servers and containers running a single system user and for single-user machines like laptops — and a poor fit for:
command="..." entries, that layer disappears.The untrusted-code case has a documented mitigation: check mode with checkPeriod "always", which triggers an SSO-based approval prompt on every new connection. It works — I see it as a reasonable trade for a CI runner, and a tedious one for a machine you touch every hour.
If your setup lands in any of those buckets, the documented answer is not to abandon the tailnet — it is to run traditional SSH servers and clients on top of Tailscale as the network layer. You keep the standard OpenSSH experience: your existing keys, your authorized_keys files, your forced commands — but with no internet exposure and end-to-end WireGuard encryption underneath. The bastion's job disappears; OpenSSH's fine-grained controls stay.
What makes migration genuinely low-risk is that the two models coexist:
Looking at that sequence, the risk at every step is close to zero, because you are never more than one config change away from falling back. That, more than any feature list, is why I stopped treating the jump server as load-bearing.

With the bastion decommissioned, my daily access path looks deceptively simple: Termius → Tailscale private network → VPS → SSH/Mosh. Termius is a cross-platform SSH client for desktop and mobile that securely syncs hosts, SSH keys, and connection preferences across all my devices — the host list I configure on my laptop shows up unchanged on my phone. Each host entry carries its own label, address, port, username, and key, so every server is one saved profile rather than a mental note.
The configuration is where the mental shift happens. Instead of entering a public IP, I point each host entry at the server's Tailscale IP — for example 100.80.40.20, port 22. Traffic then rides the tailscale0 virtual interface inside an encrypted WireGuard tunnel and never depends on the server's public address. If my provider rotates the VPS's public IP tomorrow, nothing on my side breaks.
Termius ships built-in client-side Mosh support, so there is no separate local client to install — I just select 'Mosh (Enabled)' in the Host settings. The connection sequence looks like this:
Server-side setup is a single line: sudo apt update && sudo apt install mosh -y.
Mosh (Mobile Shell) uses UDP-based state synchronization, which is what changes the experience in practice: sessions survive laptop sleep, packet loss, and network changes, and resume instantly when connectivity returns. Plain SSH handles packet loss and IP changes poorly and tends to freeze or drop, which makes Mosh the right tool for weak connections, Wi-Fi switching, and mobile access.
Here is the part that flips the old argument on its head. Because connections are peer-to-peer, traffic does not traverse a long distance through a gateway server first. A bastion-mediated session pays for a double hop — client to gateway, gateway to target — and that imposes real latency and throughput penalties. Direct tunneling removes them, which means Tailscale SSH is typically both more secure and faster than bastion-mediated access. The two goals I had assumed were in tension for years turned out to align.
Decommissioning the bastion also removes the extra machine to set up, patch, and maintain, along with its supporting cast:
The box I used to defend against the whole internet is now gone, and nothing in the daily workflow misses it.