Skip to content

Tailscale

Tailscale is a managed WireGuard mesh — your servers and clients join a private network keyed to your identity provider (Google, GitHub, Okta, etc.), and from then on each node has a private 100.x.x.x address reachable only by other tailnet members. It replaces the “open a port and hope” model with “everything is on a private overlay; the public internet sees nothing.”


Terminal window
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up

tailscale up opens a browser link to authenticate against your IdP. Once authenticated, the host joins your tailnet and gets a 100.x.x.x address. Verify:

Terminal window
tailscale status # list tailnet members
tailscale ip -4 # your tailnet IP

There are two distinct ways to use SSH with a tailnet. They look similar from the outside but differ in who authenticates the user.

Pattern 1 — Tailscale as transport (traditional SSH)

Section titled “Pattern 1 — Tailscale as transport (traditional SSH)”

Tailscale provides reachability; OpenSSH does authentication exactly as on the public internet. You still need ~/.ssh/authorized_keys on the server, host keys, sshd on :22.

Terminal window
ssh user@hostname # MagicDNS resolves short name
ssh user@hostname.tailnet-name.ts.net # FQDN form
ssh user@100.64.0.42 # raw CGNAT IP

To make sure port 22 isn’t reachable from the public internet, you have two variants — both are fine, the right pick depends on whether you also want non-Tailscale clients (e.g., users on the same physical LAN) to be able to reach SSH.

Variant A: bind sshd to the tailnet interface only (strictest — sshd doesn’t even listen on the public IP):

/etc/ssh/sshd_config
ListenAddress 100.64.0.42

(Replace with the actual tailnet IP from tailscale ip -4.)

Variant B: keep sshd on all interfaces, firewall to the Tailscale CGNAT range — useful when you want LAN clients on the same physical network or specific public IPs to also reach SSH without going through Tailscale:

Terminal window
# ufw
sudo ufw allow from 100.64.0.0/10 to any port 22 proto tcp comment "SSH via Tailscale"
sudo ufw deny 22/tcp
# iptables equivalent
sudo iptables -A INPUT -p tcp --dport 22 -s 100.64.0.0/10 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j DROP

Trade vs. Variant A: sshd is still bound to a public interface (slightly more attack surface if a future firewall rule is misconfigured), but you retain flexibility for additional allow from rules.

Pattern 2 — Tailscale SSH (Tailscale handles auth too)

Section titled “Pattern 2 — Tailscale SSH (Tailscale handles auth too)”

tailscale up --ssh on the server makes the Tailscale daemon claim port 22 on the tailnet IP and authenticate connections using tailnet identity — anchored to your IdP. No authorized_keys, no key rotation, no per-user file management. Tailnet ACL policy controls who can SSH where.

Enable on the server:

Terminal window
sudo tailscale set --ssh # add SSH to a running tailnet node
# or, if not yet joined:
sudo tailscale up --ssh
# If you're already SSH'd into the box via traditional SSH, the daemon
# restart will drop your session — acknowledge with:
sudo tailscale up --ssh --accept-risk=lose-ssh

This does not modify sshd_config or touch authorized_keys. Sshd keeps running on other interfaces. To go all-in: bind sshd off the tailnet IP, or disable it entirely — but keep an out-of-band recovery path before you do.

From the client, plain ssh works once Tailscale’s daemon is intercepting port 22 on the tailnet IP. The convenience wrapper is tailscale ssh user@host, which handles known_hosts and routing automatically.

ACL policy controls who can SSH where, and action: "check" forces interactive re-auth via your IdP every N hours for sensitive hosts. Edit via the Tailscale admin console or tailnet-policy.hujson:

{
"ssh": [
// Members SSH into their OWN devices, re-auth via IdP every 20 hours
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot"],
"checkPeriod": "20h",
},
// Admins SSH anywhere tagged "server", any non-root user
{
"action": "accept",
"src": ["group:admins"],
"dst": ["tag:server"],
"users": ["autogroup:nonroot"],
},
],
}

Root login is blocked unless users: explicitly lists rootautogroup:nonroot is the safe default. See Tailscale ACL syntax.

Tailscale SSH connections appear in the tailnet audit log (https://login.tailscale.com/admin/logs/audit). Optional session recording streams asciinema-format playback to a designated recorder node — useful for compliance, post-incident review, or just curiosity about what an automated job actually did. See SSH session recording.

Tailscale as transportTailscale SSH
SSH keysYour existing keysNone — IdP identity
Port 22 publicNo (bind to tailnet IP)No (daemon claims it on tailnet IP)
ACL granularityPer-user via authorized_keysPer-tag, per-user, per-action via tailnet policy
Audit logsshd auth.logTailnet audit log + optional session recording
Re-auth on sensitive hostsNoYes (check mode)
Non-Tailscale clients can connectYes (via WireGuard interop or other route)No — must be a tailnet member
Vendor lock-inLow — sshd is yoursHigher — tailscaled does the auth
Windows server targetYesNo
  • Tailscale SSH is the default for tailnet-only servers, mixed-skill teams, and anywhere you’d otherwise be juggling per-user keys. Especially good with check mode on production hosts.
  • Tailscale as transport is the right pick when you want hardware-key SSH auth (YubiKey-resident ed25519-sk), when non-Tailscale clients also need to connect, when you have a Windows server target, or when policy requires no-vendor-in-the-auth-path.

Headscale is the open-source self-hosted Tailscale control plane. It supports Tailscale SSH including check mode, with a CLI flow (headscale auth approve --auth-id) for check-mode approvals. Lags Tailscale SaaS by roughly a quarter on features — SSH session recording isn’t implemented, and tagged source nodes can’t use check mode (OIDC approval needs a user-owned source). For most homelab and small-team setups it’s a viable substitute; for compliance use cases requiring session recording, stick with Tailscale SaaS.



  • tailscale up --ssh drops your existing SSH session. Expected — the daemon takes over port 22. Use --accept-risk=lose-ssh to acknowledge. Have an out-of-band path (console, KVM, IPMI) ready before you toggle this on a remote host you can’t physically reach.
  • MagicDNS not resolving. tailscale set --accept-dns=true. On hosts with their own resolver (systemd-resolved, dnsmasq), Tailscale may need --accept-dns=false + a manual /etc/resolv.conf entry to avoid fighting.
  • Cannot SSH after enabling --ssh with a strict ACL. The tailnet policy might not grant your identity access. Check the ACL tester.
  • Tailscale + Cloudflare WARP on the same host — both inject routes into custom tables with their own ip rule priorities. WARP re-asserts its priority on reconnect, so hardcoded priorities lose. See the host upgrades / multi-VPN routing notes.