Originally published on kuryzhev.cloud
The scenario
Your team reaches production through one bastion host with a public IP, a shared authorized_keys file, and a ProxyJump line in everyone's SSH config. Nobody wants to patch it, and offboarding means editing keys on every box. The audit question "who logged in as root last Tuesday?" has no clean answer. Tailscale SSH with ACL tags fixes the access model: identity comes from your IdP, authorization comes from a policy file, and no server needs inbound port 22 on the internet.
Short answer:
- Install Tailscale on each server with a tag (for example
tag:prod). - Add an
sshsection to the tailnet policy file that maps groups to tags. - Verify access from a client.
- Remove public port 22 and the bastion.
This fits Linux fleets you control and can install an agent on. It does not fit appliances, managed databases, or other hosts where you cannot run the daemon. A subnet router can give those hosts network reachability, but not Tailscale SSH authorization.
Prerequisites
You need:
- A Tailscale tailnet with an identity provider connected.
- Admin rights on the policy file.
- Root on the target servers.
Linux is the well-supported SSH server platform. Check the Tailscale SSH documentation for the current OS support matrix and plan limits, since features such as session recording depend on your plan. A current LTS such as Ubuntu 26.04 is a fine target.
- Clients (laptops, CI runners) also run Tailscale and are signed in as real users or tagged service identities.
- A way to keep your current bastion working until cutover is finished. Do not delete it first.
- A list of who needs which environment, because that becomes your groups.
Watch out for the cost side. You are adding a dependency on Tailscale's coordination service, and paid plans are billed per user. Existing connections survive a control-plane blip, and nodes keep their last-known policy. However, check-mode re-authentication, policy changes, and new device enrollments all rely on the control plane. Keep a break-glass path, such as cloud-provider console access or serial console, documented and tested.
Step 1: Define tags and ssh rules in the policy file
Tags give servers an identity that is not tied to a person. Only owners listed in tagOwners (plus tailnet admins) can apply them. The policy below is HuJSON (comments allowed), edited in the admin console under Access controls. Adjust names to your own.
{ "groups": { "group:platform": ["alice@example.com", "bob@example.com"], "group:devs": ["carol@example.com"] }, "tagOwners": { "tag:prod": ["group:platform"], "tag:staging": ["group:platform"], "tag:ci": ["group:platform"] }, // Network reachability: who may open tcp/22 to which tags "grants": [ { "src": ["group:platform"], "dst": ["tag:prod", "tag:staging"], "ip": ["tcp:22"] }, { "src": ["group:devs"], "dst": ["tag:staging"], "ip": ["tcp:22"] } ], // Authorization for the SSH session itself "ssh": [ { "action": "check", // re-authenticate through the IdP "checkPeriod": "12h", "src": ["group:platform"], "dst": ["tag:prod"], "users": ["ubuntu", "root"] }, { "action": "accept", "src": ["group:devs"], "dst": ["tag:staging"], "users": ["autogroup:nonroot"] } ] } The two layers matter:
-
grants(or legacyacls) decide whether packets may reach port 22. -
sshrules decide who may log in as which local user.
A session needs both.
Gotcha: if your policy still contains the default allow-all rule, tags restrict nothing at the network layer. Remove it as part of this step. Then use the policy's tests facility (see the docs) to assert expected allow and deny cases before saving.
Step 2: Enroll servers with a tagged auth key
Create a reusable or one-off auth key in the admin console, set to apply tag:prod (or tag:staging). For larger fleets, the docs describe OAuth clients as a better way to mint keys than long-lived secrets. On each server:
# Install from the official script (review it first if your policy requires) curl -fsSL https://tailscale.com/install.sh | sh # --ssh enables Tailscale SSH; the advertised tag must match the auth key's tags sudo tailscale up \ --auth-key="tskey-auth-REDACTED" \ --advertise-tags=tag:prod \ --ssh \ --hostname=web-1 # Already enrolled? Toggle SSH without re-authenticating sudo tailscale set --ssh Keep the key in your secret manager or Ansible Vault, never in the repo. In Terraform or cloud-init, inject it at boot and let it expire after use. Remember that values passed through Terraform can end up in state.
Watch out: tagging a device makes the tag its owner. The device no longer belongs to the person who logged in, and by default its node key does not expire. That is what you want for servers. It also means a decommissioned instance must be removed from the admin console, or it lingers as a valid node.
Tailscale SSH takes over port 22 on the Tailscale address only. Your regular sshd stays reachable on the LAN or public interface until you close it in Step 4.
Step 3: Move clients off the jump host
With MagicDNS enabled, hostnames resolve inside the tailnet, so the old ProxyJump goes away. Update ~/.ssh/config and keep a short alias.
# ~/.ssh/config — before: # Host web-* # ProxyJump bastion.example.com # after: delete the ProxyJump stanza entirely, nothing replaces it. # Optional shell alias; tailscale ssh handles auth, no key file needed alias tssh='tailscale ssh' tssh ubuntu@web-1 uptime Plain ssh ubuntu@web-1 also works once the Tailscale client is running and MagicDNS resolves the name, because the server side does the authentication. Run both paths (bastion and tailnet) in parallel for a week so nobody is blocked.
For CI runners, join them to the tailnet with their own tag, for example tag:ci (declared in tagOwners above). Then add a narrowly scoped grant and an accept-mode ssh rule instead of reusing a human group. Check mode requires a person to re-authenticate, so it does not suit unattended runners.
Step 4: Close port 22 and retire the bastion
Only after Step 3 is verified for every person and pipeline, remove the public ingress. On AWS, for example:
# Remove world-open SSH from the server security group aws ec2 revoke-security-group-ingress \ --group-id sg-0123456789abcdef0 \ --protocol tcp --port 22 --cidr 0.0.0.0/0 Tailscale needs outbound connectivity only: direct UDP when possible, relayed over HTTPS otherwise. A server with zero inbound rules therefore still works.
Next, stop the bastion instance for a cooling-off period before terminating it. Delete its keys from any systems that trusted it.
Verify and test
Check enrollment and tags first:
-
tailscale statuson a client should listweb-1with its tailnet IP. - The admin console should show
tag:prodon the machine.
Then test allowed and denied paths deliberately:
tailscale status | grep web-1 # node visible, not "offline" tailscale ssh ubuntu@web-1 hostname # allowed: prints "web-1" # check mode: first login prints an IdP URL to approve # As a group:devs user: tailscale ssh ubuntu@web-1 # should be refused: devs have no rule for tag:prod # From outside the tailnet: nmap -Pn -p 22 <public-ip> # expect filtered/closed after Step 4 On the server, sudo journalctl -u tailscaled shows log entries for SSH connections, though not session contents. The admin console's audit logs track policy changes.
Next, check the offboarding and re-authentication paths:
- Remove a test user from the tailnet (or via your IdP and SCIM) and confirm that new sessions are refused. Already-open sessions may persist until they are closed or the device is removed.
- Confirm that once the
checkPeriodlapses, a fresh IdP login is required.
If you need full session recordings, consult the docs for the current feature status and plan requirements rather than assuming it is on.
Where this leaves you
You now have:
- Per-person authorization tied to your IdP.
- No shared keys.
- No internet-facing SSH.
- A policy file you can review in a pull request like any other config.
See more runbooks on kuryzhev.cloud.
The trade-offs are real: a SaaS control plane in your login path, agents on every host, and a need for a documented break-glass route.
For hosts that cannot run the agent, a tagged subnet router can provide network reachability, controlled by grants in the same policy file. Tailscale ssh rules do not apply to those hosts, so they keep their own sshd authentication.
Start with staging, and keep the bastion stopped rather than deleted for a few weeks. Read the Tailscale docs for version-specific policy syntax before you roll to production.