Tailscale
Tailscale
Section titled “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.”
Installing Tailscale
Section titled “Installing Tailscale”curl -fsSL https://tailscale.com/install.sh | shsudo tailscale uptailscale 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:
tailscale status # list tailnet memberstailscale ip -4 # your tailnet IPSSH Access — Two Patterns
Section titled “SSH Access — Two Patterns”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.
ssh user@hostname # MagicDNS resolves short namessh user@hostname.tailnet-name.ts.net # FQDN formssh user@100.64.0.42 # raw CGNAT IPTo 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):
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:
# ufwsudo ufw allow from 100.64.0.0/10 to any port 22 proto tcp comment "SSH via Tailscale"sudo ufw deny 22/tcp
# iptables equivalentsudo iptables -A INPUT -p tcp --dport 22 -s 100.64.0.0/10 -j ACCEPTsudo iptables -A INPUT -p tcp --dport 22 -j DROPTrade 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:
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-sshThis 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.
Tailnet ACL example
Section titled “Tailnet ACL example”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 root — autogroup:nonroot is the safe default. See Tailscale ACL syntax.
Audit and session recording
Section titled “Audit and session recording”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.
Tradeoffs
Section titled “Tradeoffs”| Tailscale as transport | Tailscale SSH | |
|---|---|---|
| SSH keys | Your existing keys | None — IdP identity |
| Port 22 public | No (bind to tailnet IP) | No (daemon claims it on tailnet IP) |
| ACL granularity | Per-user via authorized_keys | Per-tag, per-user, per-action via tailnet policy |
| Audit log | sshd auth.log | Tailnet audit log + optional session recording |
| Re-auth on sensitive hosts | No | Yes (check mode) |
| Non-Tailscale clients can connect | Yes (via WireGuard interop or other route) | No — must be a tailnet member |
| Vendor lock-in | Low — sshd is yours | Higher — tailscaled does the auth |
| Windows server target | Yes | No |
When to use which
Section titled “When to use which”- 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
checkmode 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 (self-hosted control plane)
Section titled “Headscale (self-hosted control plane)”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.
Cross-references
Section titled “Cross-references”- SSH & GPG Keys → SSH Through a Tunnel or Mesh VPN — the decision matrix vs. Cloudflare Tunnel alternatives
- Self-host Cloudflare Tunnels — the other “hide port 22” path
- Tailscale Exit Node — using a tailnet host as an outbound VPN exit
Troubleshooting
Section titled “Troubleshooting”tailscale up --sshdrops your existing SSH session. Expected — the daemon takes over port 22. Use--accept-risk=lose-sshto 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.confentry to avoid fighting. - Cannot SSH after enabling
--sshwith 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 rulepriorities. WARP re-asserts its priority on reconnect, so hardcoded priorities lose. See the host upgrades / multi-VPN routing notes.