Tailscale SSH for an AI agent server: tags, ACLs, key expiry
By default an AI agent's server on your tailnet can reach your laptop. Tag it, let only your devices connect, SSH in with no keys, and turn off key expiry.
Out of the box, a tailnet lets every device start connections to every other, so a server you add for an AI agent can reach your laptop, your phone and whatever they serve, and so can any command the agent is tricked into running. Tag the server, replace the allow-all rule with a grant that only your own devices match, and add a Tailscale SSH rule from your devices to the tag. You then SSH in with no keys to manage and no need for a public port 22, and the server cannot open a connection to anything else on the tailnet. Check the result in your devices’ own packet filters, and turn off the server’s key expiry yourself: tagging it in the admin console leaves the expiry in place.
Why the default is wrong for this server
Tailscale’s default policy has one network rule, every device to every device, and in Tailscale’s words it “lets all devices in the tailnet access all other devices in the tailnet” (Tailscale’s description of the default policy). Between your own laptop and phone that is convenient. A server running an agent is a different kind of device. The agent reads web pages, email and code that other people wrote, and text it reads can carry instructions it will follow. Whatever it runs inherits the server’s place on your tailnet, and by default that place has a route to every device you own and anything listening on them, such as a dev server on your laptop or a file share at home.
The SSH rules have the same shape. Tailscale’s default SSH rule lets each person SSH into their own devices, and a server you signed in to Tailscale as yourself is one of your own devices, as a source as well as a destination. Tailscale’s SSH documentation lists “machines that have outbound Tailscale SSH access, where you do not trust the code running on those machines” among the setups it does not suit (Tailscale SSH’s limitations).
The policy: only your own devices start connections
Tailscale’s rules are directional: a rule lets a source start connections to a destination, which answers on them but cannot start its own unless another rule allows it (how access rules behave). So the fix is to name who may start connections. This policy does it with a tag for the server, in the grants syntax Tailscale recommends for new policies; the older ACL syntax still works but gets no new features (grants versus ACLs). On a tailnet still on the default policy, it replaces the whole file on the admin console’s Access controls page:
{
"tagOwners": {
"tag:agent-server": ["autogroup:admin"]
},
"grants": [
{
"src": ["autogroup:member"],
"dst": ["*"],
"ip": ["*"]
}
],
"ssh": [
{
"action": "check",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"]
},
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["tag:agent-server"],
"users": ["autogroup:nonroot", "root"]
}
]
}- tagOwners creates
tag:agent-serverand lets admins apply it. A tag has to be defined here before a device can carry it, and a tagged device stops being yours: applying a tag removes the device’s user identity, and the tag becomes its identity instead (Tailscale on tags). - The grant lets
autogroup:member, the devices of the people who are members of your tailnet, start connections to anything on any port. A tagged device is not a member’s device, and no rule names the tag as a source, so the server can start no connection to anything on your tailnet. Your devices still reach every port on it, which covers anything you run there for yourself, such as an agent’s web dashboard, without putting it on the public internet. - The first SSH rule is Tailscale’s default, kept: you can SSH into your own devices in check mode, which asks you to sign in again in a browser, every 12 hours by default.
- The second SSH rule lets your devices into the tagged server as any non-root user or as root, with no prompt. Make it
checkinstead ofacceptif you want that sign-in before sessions there too. Tagged devices can only SSH into other tagged devices, and nothing lets this tag start a session, so the server has no Tailscale SSH route anywhere, itself included, and Tailscale SSH offers no way to root from inside it.
What quietly undoes it
- A leftover allow-all rule. When several grants match a connection, Tailscale applies all of them together, and a more specific grant never overrides a broader one (the grants syntax). A rule from
*to*left anywhere in the file, including in an olderaclssection, keeps the server able to reach everything. If your file usesaclsrather than grants, replace its allow-all rule with this one (the policy editor’s Convert to grants button rewrites anaclssection as grants, if you would rather switch):"acls": [ { "action": "accept", "src": ["autogroup:member"], "dst": ["*:*"] } ] - Other people in your tailnet.
autogroup:memberis every member, so the grant lets other members’ devices reach yours, as the default did. Tailscale also warns thatautogroup:nonrootin an SSH rule to a tag lets every allowed source in as any non-root user on that device (the autogroups reference). If you are not alone in the tailnet, replaceautogroup:memberwith your own login, such asyou@example.comoryourname@githubfor a GitHub sign-in, andautogroup:nonrootwith your username on the server. - Other tagged devices. A subnet router or another tagged server loses its outbound connections under this policy too. Give each one a grant for what it actually needs.
- An exit node. If the server sends its traffic out through an exit node at home, it needs a grant of its own, because only devices with access to
autogroup:internetcan use exit nodes (the policy file reference). Add this togrants:{ "src": ["tag:agent-server"], "dst": ["autogroup:internet"], "ip": ["*"] }
The policy file can also carry tests, assertions checked every time the file changes, which make the editor reject an edit that breaks them. One with tag:agent-server as its source and your own login with a port, such as 22, under deny catches a later edit that reopens the route.
Tag the server, after the policy
Save the policy first. Tailscale’s default SSH rule covers a person’s own devices and never tagged ones, so a server tagged while the default policy is in force loses Tailscale SSH until the rule for its tag exists.
A server joining the tailnet now
After installing Tailscale on the server, run:
sudo tailscale up --ssh --advertise-tags=tag:agent-serverOpen the link it prints and sign in as the tailnet’s owner or an admin. The server joins as tag:agent-server with Tailscale SSH switched on, and a device that authenticates with a tag for the first time gets key expiry disabled by default, so the expiry step below becomes a check rather than a change.
A server already on your tailnet
In the admin console, open Machines, then the menu at the end of the server’s row, then Edit tags, and add tag:agent-server. An admin can do this without re-authenticating the device. If Tailscale SSH is not on yet, run sudo tailscale set --ssh on the server over some connection other than its Tailscale address, since Tailscale warns that the command makes existing SSH sessions to that address hang. Then do the key expiry step below, because this route leaves the expiry on.
From any of your devices, connect with an ordinary SSH client, using the server’s name on the tailnet:
ssh <user>@<server-name>There is no key and no password: Tailscale already knows which of your devices is connecting. From a phone, run the Tailscale app alongside an SSH client. If the client refuses to connect without a password, Tailscale’s documented workaround is to add +password to the username and type anything at the password prompt.
Check it where it is enforced
The policy file says what you meant. What happens is decided by each device’s own packet filter: Tailscale compiles the policy into rules for each device, and the device enforces them on incoming connections itself, without Tailscale’s servers in the loop. So the proof that the server cannot reach your laptop is on the laptop, and since the laptop enforces it, nothing done on the server, as root or otherwise, changes what the laptop accepts.
Print a device’s current network map, which includes its packet filter (on Linux, with sudo):
tailscale debug netmapUnder PacketFilterRules, each rule’s SrcIPs lists the addresses allowed to start connections to that device, and a * there admits every device. On your laptop, the server’s Tailscale address, which tailscale ip -4 prints on the server, must not appear. On the server, your devices’ addresses should. This is a debug command, and the shape of its output is internal to Tailscale and can change; those are the field names in Tailscale’s source as of October 2026.
We read both filters on a tailnet set up this way on September 29, 2026. The laptop’s admitted only the owner’s own devices and not the server, the server’s admitted the laptop, SSH from the laptop to the server worked, and the server could not open a Tailscale SSH session to itself.
For a quicker check from the server, Tailscale’s docs describe a pair of pings (debugging access rules). A TSMP ping stops before the access check, so it should answer; an ICMP ping goes through it, so it should not:
tailscale ping --tsmp <laptop-name> # answers: the devices can connect
tailscale ping --icmp <laptop-name> # no reply: the laptop's rules refuse itTurn off key expiry yourself
Each device’s key expires unless you turn that off, after 180 days by default on a new tailnet, and when it does, connections to and from the device stop (Tailscale on key expiry). For a server you reach only through Tailscale, that cuts you off on a date you did not pick, until you extend the key from the admin console or get in through your provider’s web console.
Tailscale’s docs say that a device which first authenticates with a tag has key expiry disabled by default, and that changing a device’s tags from the admin console, the CLI or the API leaves its expiry as it was until the device re-authenticates (key expiry for tagged devices). That is what we saw on September 29, 2026: a server tagged in the admin console still had a key expiry about six months out. Turn it off from the same menu (Machines, the menu at the end of the server’s row, Disable key expiry), then confirm on the server:
tailscale status --json --peers=false | grep KeyExpiryIt prints the expiry date while one is set and nothing once expiry is off. Re-authenticating with tailscale up --force-reauth would also clear it, but Tailscale warns that doing so can drop the tailnet connection, so it is not a command to run over Tailscale SSH. If a key has already expired, the same menu offers Temporarily extend key, which gives you 30 minutes to get in and disable expiry.
Then close port 22
Tailscale SSH does not use the server’s own SSH daemon. It takes port 22 on the server’s Tailscale address only, and it leaves sshd_config and authorized_keys untouched, so the ordinary SSH server keeps listening on the public address (Tailscale SSH, how it works). Once Tailscale SSH works, sshd is left with one job: answering the internet. Confirm Tailscale SSH from a second terminal, know where your provider’s web console is, then switch sshd off. On Ubuntu 24.04, where sshd is socket-activated, both units have to go:
sudo systemctl disable --now ssh.socket ssh.service
sudo ss -tlnp 'sport = :22'Disabling only ssh.service is not enough: the socket keeps listening on port 22 and starts sshd again on the next connection. The ss command should print its header line and nothing else. On Ubuntu 24.04 on September 29, 2026, with sshd switched off, nothing listened on port 22 and Tailscale SSH kept working. On other distributions the unit is usually ssh or sshd. If you would rather keep sshd as a second way in, make it keys-only instead.
The provider’s firewall can then hold no inbound rules at all, a property Tailscale and Cloudflare Tunnel share. Tailscale’s own answer to which ports to open is that most of the time none need opening (Tailscale’s firewall ports FAQ), and in our check the same day, a laptop connected directly, not through a relay, to a server behind a provider firewall with no inbound rules. tailscale status shows which you have: direct with an address and port, or relay with the relay’s city code.