I moved an AI agent off my own machine and onto a server behind Tailscale. Closing off inbound was easy. The real lesson was elsewhere: in a mesh, every connection runs both ways, and the boundary has to be drawn not only on the network but inside the server too.
Why I did this
The first time I ran Claude Code on my MacBook, I gave an AI agent access to my file system and a shell: the same file system that holds my SSH keys, my projects, my configs and years’ worth of work files.
It wasn’t that I distrusted the agent. The discomfort was more basic: I hadn’t drawn a trust boundary. On the network side, that’s always been the first thing I do: where does this segment end, and what does this ACL let through? My MacBook, on the other hand, was like a flat network, with everything on it at the same trust level. In any professional setting, I’d never accept that.
There was a more mundane problem too: I’d switch between the MacBook and the Fedora desktop on the same project, each
with its own Node version and a half-finished .env. Both were symptoms of the same problem: my development environment
didn’t live in one place, and none of its parts had a defined boundary around it.
Restricting where an agent runs is a much simpler and much more auditable problem than restricting what it can do.
I use Claude Code’s own permission mechanisms as well. But instead of relying on a single control, I wanted a second, independent layer: a boundary drawn around the agent, not inside it.
What I tried
Local VM (OrbStack). An Ubuntu VM on macOS: fast, with impressive integration. It solved isolation, but the environment stayed tied to the MacBook: it stopped when I closed the lid and couldn’t be reached from Fedora. It wasn’t wasted, though: it’s where I rehearsed everything below before moving it to a real server.
VPS + plain SSH. Isolation and continuity, sorted. But port 22 was open on a public IP, and every device meant
another authorized_keys entry to manage. I’d drawn the boundary in the right place and then put the door where
everyone could see it.
Tailscale. I changed the model, not the tool: I set up Tailscale not as a “remote access VPN” but as my own private network layer that my devices sit on.
From a network perspective
The quickest way to understand Tailscale is to break it down into concepts a network engineer already knows:
- Control plane (centralized). Authenticates identities and distributes keys and policy to the nodes. It carries no traffic.
- Data plane (peer to peer). Packets flow directly between devices over WireGuard tunnels; devices behind NAT find each other with STUN and hole punching.
- Fallback (DERP). If a direct path can’t be established, traffic goes through a relay; the relay carries the packets but can’t see inside them.
In practice, port forwarding stopped being a thing for me, and the answer to “who can reach this?” is an identity, not an IP range. There’s also a less obvious detail: the policy is written centrally but enforced separately on every node. So it’s not a perimeter firewall but a distributed host firewall. That will matter later on.
The final architecture
One rule: the session lives on the server, the client is just a window. Claude Code, the files and the tmux session
all live on the ubuntu server. tmux is what makes this work: it ties the agent to the server, not to my connection. I
can start a task on the MacBook, check on it from my phone on the go and pick it up from Fedora in the evening. Even if
I close the MacBook’s lid or the connection drops, Claude keeps working, and when I come back everything is where I left
it. Whether it’s the terminal or VS Code on macOS and Fedora, or Termius on my phone, I always land in the same session.
And I draw the boundary in three places:
- On the network. The tailnet policy: who, to which port, in which direction, as which user.
- On the devices. Tailnet Lock: my devices decide who can join the tailnet, not the control plane.
- Inside the server. The agent and the admin are separate users: even if the agent is compromised, it can’t reach system administration.
Below are only the lines that carry a decision; this isn’t a step-by-step setup.
Access: identity, not keys
sudo tailscale up --hostname=ubuntu
sudo tailscale set --ssh --auto-updateWith --ssh, tailscaled handles traffic arriving on port 22 from the tailnet, and the access decision moves from
authorized_keys to the tailnet policy. The question isn’t “which key” but “which identity”. There are no SSH keys
to hand out to clients or to lose. --auto-update, meanwhile, isn’t a convenience but a security control: the moment I
hand the access decision over to tailscaled, its vulnerabilities become mine. I don’t leave patching up to my memory.
There’s one thing I deliberately stay away from: making the agent’s user an --operator. An operator can run
tailscale serve and funnel without sudo, so even without sudo the agent could expose a port on the server to the
tailnet, or even to the internet.
Network: a directional policy
The policy says not only who can reach what, but also which user you can log in as on the server, and it tests itself:
{
"tagOwners": { "tag:dev-server": ["[email protected]"] },
"grants": [{ "src": ["[email protected]"], "dst": ["tag:dev-server"], "ip": ["tcp:22"] }],
"tests": [
{
"src": "[email protected]",
"accept": ["tag:dev-server:22"],
"deny": ["tag:dev-server:80", "tag:dev-server:8080"]
}
],
"ssh": [
{
"action": "accept",
"src": ["[email protected]"],
"dst": ["tag:dev-server"],
"users": ["devuser", "sysadmin"]
}
],
"sshTests": [
{
"src": "[email protected]",
"dst": ["tag:dev-server"],
"accept": ["devuser", "sysadmin"],
"deny": ["root"]
}
]
}- There are no rules from the server to the clients or between clients; because the policy is stateful, return traffic for the sessions I open still gets through.
- I listed the users one by one instead of using
autogroup:nonroot, because on a tagged destination that means “any user other than root”. testsandsshTestsrun when the policy is saved: if someone (me included) later allows port 80 or 8080, or addsroot, the policy won’t save. Think of it as a pre-commit check for the config.
Key expiry is off by default on tagged nodes; I turned it on deliberately. I don’t want a stolen server key to stay valid forever.
Inside the server: two users
Tailscale SSH logs you in without asking for a password. So once a device reaches the server, what it can do there comes down entirely to which user it logs in as. That’s why:
devuser: Claude Code, projects, tmux. Nosudo, no password; not in thedockergroup either (that group is effectively root).sysadmin: apt, services, ufw, Tailscale.sudoonly with a password.root: denied from the tailnet; its only way in is the provider’s web console.
Home directories are 750: no permissions at all for “others”, and devuser isn’t in the sysadmin group. So the
agent can’t read sysadmin’s files. I never run Claude Code as sysadmin.
The real trap wasn’t the new users but the ubuntu user that came with the image:
$ sudo adduser sysadmin # set a password
$ sudo usermod -aG sudo sysadmin
$ sudo adduser --disabled-password devuser # no password, not in the sudo group
$ ls -ld /home/* # 750 by default on Ubuntu; if not: sudo chmod 750 /home/*
drwxr-x--- 5 devuser devuser ... /home/devuser
drwxr-x--- 5 sysadmin sysadmin ... /home/sysadmin
$ sudo grep -rn NOPASSWD /etc/sudoers.d/
/etc/sudoers.d/90-cloud-init-users:ubuntu ALL=(ALL) NOPASSWD:ALL$ sudo -v # first: does sudo with a password work?
$ sudo rm /etc/sudoers.d/90-cloud-init-users
$ sudo usermod -L -s /usr/sbin/nologin ubuntu # lock it, disable its shell
$ sudo deluser ubuntu sudoTailscale SSH doesn’t ask for a password, and the image doesn’t ask for one for sudo; put the two together and you get
passwordless root from any device on my tailnet. After confirming in a second window that sysadmin’s
password-protected sudo worked, I deleted the NOPASSWD:ALL line along with the file it lived in, then locked the
default user and removed it from the sudo group. After a reboot the file had been written back, so now I override it
with a zz- file, which sorts last and gets the final word.
Clients: terminal-first
Claude Code is a terminal-first tool, and most of the time I’m not writing code, I’m reading diffs. So the real
question wasn’t “which editor?” but “where does the session live?” The answer is tmux. From any client on the
tailnet, I connect to the server as devuser and open the session:
ssh devuser@ubuntutmux new -A -s claude # attach if the session exists, create it if not- macOS and Fedora. Day-to-day work happens in the terminal. When I’m reviewing a large diff, I use VS Code
Remote-SSH, always as
devuser. I turn off one setting in VS Code: I open Settings (Cmd + ,/Ctrl + ,), typeEnable Agent Forwardinginto the search box and untick Remote.SSH: Enable Agent Forwarding. Leave it on, and anyone on the server, the agent included, can use my SSH keys for as long as I’m connected. - Android and iOS. With the Tailscale app open and connected to the tailnet, I add a new host in Termius: label
ubuntu, the full MagicDNS name as the address (ubuntu.tailxxxx.ts.net), usernamedevuser. The password field stays empty; Tailscale verifies the identity, and there’s no key or password stored on the phone.
Filtering: three layers
I don’t rely on a single firewall but on layers that sit in different places:
| Layer | Where | What it does |
|---|---|---|
| Provider firewall | Edge | Inbound, only |
| ufw | Host | Allows what comes in on |
| Tailnet ACL | On every node | Who, to which port, in which direction, as which user |
sudo ufw default deny incoming
sudo ufw default allow outgoing # ← outbound is open, see Trade-offs
sudo ufw allow in on tailscale0
sudo ufw allow 41641/udp # for the direct WireGuard path
sudo ufw enable # last41641/udp is open, but it’s a quiet door: it makes a direct path more likely, and because WireGuard never responds to
unauthenticated packets, it stays silent in a port scan.
Device: Tailnet Lock
The policy is tied to the user and doesn’t tell devices apart: every device logged in with my account is “me” as far as the policy is concerned. If my account, or the control plane itself, is compromised, a foreign node joins the tailnet and every rule above treats it as “me” too. Every other layer in this setup rests on that one assumption; Tailnet Lock removes it.
With the lock on, a new node stays “Locked out” until a signing device signs it, even if it logged in with my account, and its peers won’t talk to it. The control plane now only registers; trust comes from my device.
Tailnet Lock needs at least two signers. Here’s the setup in the diagram:
- Primary signer: a desktop that runs the CLI. macOS or Fedora Workstation, whichever is at hand. It’s the one that sets up the lock and signs new devices.
- Backup signer: an iOS device. When the desktop isn’t with me, or if it’s lost, I can open the signing link or QR code from the console on my phone and sign there.
- Android is only a client. It wasn’t chosen as a signer.
I deliberately gave the server no signing rights: the place where the agent runs shouldn’t be the place that approves new devices. Order matters: add the signers before the lock, then turn the lock on.
$ tailscale lock init … # the command the console generates
$ tailscale lock sign nodekey:… tlpub:… # for every new device after the lockWhat broke
1. I locked myself out
On my first attempt, I enabled the firewall without verifying the Tailscale connection: Tailscale hadn’t come up
properly yet, and public SSH was already closed. I had to log in through the provider’s web console and run
ufw disable.
On the network side, this has a well-known name: closing the old management path before verifying the new one. On a
remote switch, I’d have left myself a way back with reload in 10; here I hadn’t. My rule now: test console access up
front, keep a second session open, ufw enable last.
2. Latency: putting the boundary in the wrong place
First I tried working with the files mounted over the network. bun install went from seconds to minutes: every one of
thousands of small file operations was crossing a boundary. Once the files moved to the server’s own disk, the problem
went away. It’s the file system version of running a chatty protocol over a WAN.
3. The tunnel survived, the session didn’t
I’d given Claude Code a long task from Termius, and the app crashed. The moment the connection dropped, the server shut down everything running in that session, and Claude Code went with it. The WireGuard tunnel stays up even when the network changes, but the SSH connection inside it doesn’t. A resilient tunnel doesn’t mean a resilient session inside it. tmux solves this: it runs Claude independently of the SSH connection, so it keeps living on the server even if the connection drops. That failure is the real reason I went terminal-first.
4. I’d only drawn half the boundary
A new tailnet starts with a default policy that opens every device to every other device. The moment I added the server, the machine the agent runs on could also start connections to my MacBook and my other devices. I’d moved the agent off my MacBook, only to give it a way back to my MacBook.
The fix is to make the tailnet policy in the admin console directional: only from my identity to tag:dev-server, only
tcp:22. There’s no rule that lets the server start connections to the clients; replies to the connections I open still
come back. The full policy is in the architecture section.
5. I never drew the boundary inside the server
I didn’t catch this myself; a published Tailscale SSH vulnerability did. The chain, in short: a legitimate SSH session opened from an authorized device could be turned into root on the server through a flaw in the daemon, and from there into a rogue node and lateral movement inside the tailnet. The attack didn’t come from outside; it came from inside an authenticated session.
After reading the news, I looked at my own server. Everything on the network layer was in place: a directional ACL,
public SSH closed, a single quiet UDP port. But there was only one user inside the server, and Claude Code was running
as that user: the image’s default user with NOPASSWD:ALL, described above. In my
setup, the chain’s root step didn’t even need the vulnerability.
The ACL told me who could reach the server, not what could happen once they got there. It’s like putting a server in the right VLAN and forgetting that everyone on it is an admin. That’s why I moved to two users. Today, every path to root, and from there into the tailnet, is cut by a separate layer:
| Path | What cuts it |
|---|---|
| Root through the daemon flaw | Current version + |
Root through | No sudo for |
| Root login from the tailnet |
|
| Rogue node | Tailnet Lock; the server has no signing rights |
| Lateral movement in the tailnet | Directional grant |
Only the first row depends on the patch. Even if the patch had never shipped, an attacker would get root on the server but stay there: a rogue node would run into Tailnet Lock, and lateral movement into the directional policy. That’s exactly what defense in depth is.
Trade-offs
| What I gained | What it cost me |
|---|---|
No re-authentication on every connection ( | Anyone who gets hold of one of my devices while it’s unlocked connects without extra verification; mitigated
by device locks (FileVault, a short screen lock, a passkey on the account) and |
| I decide who joins the tailnet (Tailnet Lock) | A signing step for every new device, and for the server when its key expires |
| No open ports, identity-based access | Identity and policy live in Tailscale’s control plane; keeping them fully in my hands would mean Headscale and its upkeep |
| The session isn’t tied to my MacBook; I can pick it up from any device | If the server goes down or I’m offline, I can’t work; constant |
| The agent can use packages, git and APIs freely | Outbound from the server to the internet is open; the agent could move data out if it wanted to. Restricting egress is my next job. |
What I learned
What really changed was the question itself. I started with “how do I run an AI agent safely?”, and that led me to permission lists. Once I switched to a network perspective, the question became “which segment should this workload run in?” That’s a question I’ve been answering for years.
Second: segmentation is a matter of direction. Closing inbound is only half the boundary; in a mesh, every connection is born two-way.
Third: segmentation doesn’t replace host hardening. The network boundary decides who can reach the server, the device boundary decides who can join the tailnet, and the server boundary decides what whoever gets in can do there. Skip one, and the other two are only half done.
And the most uncomfortable one: habits don’t carry over on their own when the tools change. Everything I’d do by reflex on a remote switch, I skipped on my own server, because there I didn’t think I was “doing networking”.
I didn’t give up convenience. I just drew its limits the way I’d draw a network’s: who, from where, in which direction, as which user, and with whose signature.
Sources
- Tailscale: Tailscale SSH findings and fixes
- Tailscale Security Bulletins
- Tailscale SSH documentation
- Tailnet Lock documentation
- Tailnet policy file syntax (grants, tests, sshTests)
- Claude Code: Security
This post isn’t a setup guide; it’s an engineering log. The commands are here for context and will go stale as versions change. For the critical steps, refer to Tailscale’s and Claude Code’s own documentation.