Your AI Agent Behind Tailscale: Zero Public Ports, Including SSH
Most hardening guides end at “use key-based SSH and enable a firewall.” That’s fine, and it still leaves port 22 answering every scanner on the internet.
For a personal AI agent you can do considerably better, because of something specific to this workload: an agent connects outward. It polls Telegram, it holds an outbound WhatsApp Web session, it calls model APIs. Almost nothing needs to reach in.
That means the target isn’t a well-configured firewall. It’s no public listening ports at all — not 80, not 443, not 22 — with every route in running over a private mesh.
Running a homelab rather than an agent? Best VPS for self-hosting applies the same architecture — VPS as the only public surface, everything valuable at home — and prices the bandwidth, where metered clouds cost around 100x a flat-rate host.
Why this works for agents specifically
Worth establishing before you build it, because the usual objection is “but the platforms need to reach my server.”
For the two most common channels, they don’t:
- Telegram — OpenClaw’s documentation is explicit that “long polling is the default transport; webhook mode is optional.” Your agent asks Telegram for updates. Telegram never initiates a connection to you.
- WhatsApp — connects via WhatsApp Web, an outbound session your gateway opens and holds.
So the default configuration of a typical agent needs zero inbound ports. The exception is genuine third-party webhooks — Stripe, GitHub, a form provider POSTing to you — and there’s a specific answer for those at the end.
The build
1. Install Tailscale and join the tailnet
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
Authenticate in the browser link it prints. The node now has a stable tailnet IP that only your devices can route to.
2. Enable Tailscale SSH
sudo tailscale set --ssh
This generates a host key pair and configures tailscaled to intercept SSH traffic. Access is then governed by your tailnet policy rather than by authorized_keys — Tailscale uses WireGuard keys that “are automatically generated and expire after a session ends.”
Here is the part almost every guide gets wrong. Enabling Tailscale SSH does not close public SSH. Tailscale’s own documentation is precise: it “claims port 22 for the Tailscale IP address (that is, only for traffic coming from your tailnet),” and it does not modify /etc/ssh/sshd_config or ~/.ssh/authorized_keys. Your normal sshd is still listening on the public interface exactly as before.
Closing it is a separate, deliberate step — and the order matters enormously.
3. Write the access policy
Tailscale SSH needs two rules in the tailnet policy file: a network rule permitting the connection, and an SSH rule naming which local users may be assumed.
{
"action": "accept",
"src": ["user@example.com"],
"dst": ["tag:agent"],
"users": ["ubuntu", "root"]
}
action takes "accept" or "check". Use "check" for anything that matters — it forces re-authentication before the session opens, so a stolen laptop session doesn’t quietly become root on your agent.
Tag the node (tag:agent) rather than writing rules against a hostname. Tags survive rebuilds; hostnames drift.
4. Verify before you close anything
Do not skip this. Open a second terminal and confirm you can get in over the tailnet while your original session is still connected:
tailscale status
ssh ubuntu@agent # over the tailnet, from another device
If that second session works, you have a way back in. If it doesn’t and you’ve already firewalled port 22, your only remedy is your provider’s web console — assuming they have one.
5. Deny everything except the tailnet
Now the firewall, using Tailscale’s own recommended rules:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow in on tailscale0
sudo ufw delete 22/tcp
sudo ufw enable
That interface rule is the whole trick: allow in on tailscale0 permits anything arriving over the mesh, while the default denies everything arriving on the public interface. Tailscale’s guidance is to “delete every rule except for the ‘Anywhere on tailscale0’ rule,” at which point “SSH can only occur over Tailscale.”
One caveat that will undo all of this: if you run your agent in Docker with published ports, ufw does not filter them. Docker’s own documentation states that container traffic is routed before the chains ufw uses, “effectively ignoring your firewall configuration.” Publish to 127.0.0.1 explicitly, or filter via DOCKER-USER.
6. Prove it from outside
A firewall you haven’t tested from off the box is a belief. From another machine on a different network:
nmap -Pn -p 22,80,443,3000,11434,5678 YOUR.SERVER.IP
Everything should read filtered or closed. Our exposure audit covers the full check, including the part where you confirm the agent’s own endpoint doesn’t answer strangers.
The webhook exception
If something genuinely must reach in — Stripe, GitHub, a payment provider — you have two honest options.
Tailscale Funnel routes public internet traffic to a service on your tailnet without the caller needing Tailscale:
tailscale funnel 3000
Know its constraints before you build on it, because they’re real:
| Constraint | Detail |
|---|---|
| Ports | Only 443, 8443 and 10000 |
| Hostname | Only your tailnet’s *.ts.net domain — not your own domain |
| Transport | TLS only |
| Bandwidth | Subject to non-configurable limits |
| Setup | Needs MagicDNS, HTTPS, and a funnel node attribute in the policy file |
The *.ts.net restriction is the one that decides it. If a third party needs to POST to hooks.yourdomain.com, Funnel can’t give you that. If it just needs a reachable HTTPS URL — which is true of most webhook configurations — Funnel is a clean answer that keeps your firewall shut.
Don’t confuse Funnel with Serve. Tailscale’s docs draw the line clearly: if you want to share a service “only with other devices in your tailnet, use Tailscale Serve instead.” Serve is for your dashboard; Funnel is for the outside world. Reaching for Funnel when Serve would do puts a service on the public internet you didn’t need to expose.
The alternative is a narrowly scoped 443 exception — a reverse proxy that accepts only that one path, ideally with source-IP restrictions to the provider’s published ranges, and authentication on the endpoint. More work, and the right answer when the webhook must arrive on your own domain.
Either way, keep the exception to the webhook receiver. The agent’s dashboard, its API and SSH all stay tailnet-only.
What you end up with
- No public listening ports, including SSH
- Access authenticated by your identity provider rather than a key file on a laptop
- Session credentials that expire rather than persist
- A scanner sees nothing — you can’t attack a port that isn’t answering
The cost is a dependency: if Tailscale’s control plane is unreachable and you have no existing session, you’re relying on your provider’s console. Know where that console is before you need it. On a personal agent this is a good trade; on infrastructure other people depend on, keep a documented break-glass path.
Where to run it
Any VPS works, since this adds no requirements beyond an outbound connection. Size for the agent rather than the network: 4GB for a text-only agent, 8GB once browser automation is involved.
Hetzner is the cheapest per gigabyte when its CX tier is in stock — worth checking before you commit, because that tier was showing as unavailable when we last looked, and the CPX line you’d fall back to is several times dearer. A plain Ubuntu box is exactly what this build wants. Hostinger is the easier path if you’d rather start from a one-click agent template and add the network layer afterwards. Cloudzy bills monthly with no term, which suits a machine you might keep for a few months.
DigitalOcean is also a common choice here, though its tracked link isn’t currently working on our end, so we’ve left it unlinked rather than send you to a dead destination.
Our best VPS for OpenClaw guide covers sizing, and self-hosting OpenClaw on a VPS covers the install with hardening first.
How we checked this
Every command and constraint here is from Tailscale’s own documentation, read in August 2026: tailscale set --ssh and its behaviour of claiming port 22 only for tailnet traffic without modifying sshd_config or authorized_keys; the ACL structure with accept and check actions; the ufw sequence including allow in on tailscale0 and deleting the public SSH rule; and Funnel’s limits on ports 443/8443/10000, *.ts.net-only hostnames, TLS-only transport and non-configurable bandwidth. That Telegram uses long polling by default is from OpenClaw’s channel documentation, and the Docker ufw interaction is quoted from Docker’s own docs.
What we did not do: we did not build this on a fresh server for this article. The sequence is assembled from documented behaviour, and the step we’d treat as mandatory regardless of how confident you feel is step 4 — opening a second session over the tailnet before deleting the public SSH rule. Locking yourself out of a remote server is the one mistake in this guide that isn’t quickly reversible, and the order above exists specifically to prevent it.
We also haven’t tested Funnel’s bandwidth limits, because Tailscale doesn’t publish the figures. If a webhook endpoint is going to carry meaningful volume, treat that as unquantified rather than assuming it’s fine.
The three hosting links above are affiliate links. Tailscale itself has a free tier that covers personal use, and we earn nothing from recommending it.
FAQ
Does Tailscale SSH close port 22?
No. It claims port 22 for the Tailscale IP only — traffic from your tailnet — and leaves sshd listening on the public interface untouched, since it doesn’t modify sshd_config or authorized_keys. Closing public SSH is a separate firewall step.
Can my AI agent work with no inbound ports at all?
Usually yes. Telegram uses long polling by default and WhatsApp Web is an outbound session, so the agent initiates every connection. Only genuine third-party webhooks need an inbound route.
What’s the difference between Tailscale Serve and Funnel?
Serve shares a service with devices on your tailnet; Funnel exposes it to the public internet. Use Serve for your agent’s dashboard and Funnel only when an outside system genuinely has to reach you.
What are Tailscale Funnel’s limitations?
It listens only on ports 443, 8443 and 10000, works only over TLS, only serves hostnames in your tailnet’s *.ts.net domain, and is subject to non-configurable bandwidth limits. If a provider must POST to your own domain, Funnel won’t do it.
How do I avoid locking myself out?
Open a second session over the tailnet and confirm it works while your original session is still live, then delete the public SSH rule. Never close port 22 from the only session you have, and know where your provider’s web console is beforehand.
Will ufw protect my Docker containers?
No. Docker publishes ports in a way that bypasses ufw’s chains entirely. Bind published ports to 127.0.0.1 or filter with the DOCKER-USER chain, then verify from an external machine rather than assuming.
Is Tailscale free for this?
Its free tier covers personal use comfortably, which is the scenario in this guide. The costs here are a VPS and your time.