Kill Your Public Management Ports: Zero-Trust Access with Tailscale
Port 22, open to the entire internet, isn’t a convenience. It’s a bet you’re making against every bot on the planet, every single day, without noticing you placed it.
Look at the failed-login log on almost any droplet with SSH open to 0.0.0.0/0 and you’ll see the same thing: hundreds of attempts a day, from IPs you’ve never heard of, guessing admin, root, pi. None of it is personal. It’s just the ambient noise of the internet, probing every open door it can find, all the time, forever. Most of those attempts go nowhere. But “most” is doing a lot of work in that sentence, and it’s not a word I want anywhere near my own infrastructure.
So when I stood up vor, the self-hosted analytics stack now running this site’s own traffic, I built its admin dashboard so it has no public door to knock on at all.
The shape of the problem
A management dashboard is a strange thing to expose to the internet. Almost nobody who requests it is you. It’s bots, scanners, and the occasional opportunist checking whether that login form has a weak password behind it. The entire audience for a Grafana instance or a Plausible dashboard is, realistically, one person: whoever’s running it. Everyone else who finds it is either lost or looking for a way in.
The old answer to that mismatch was a VPN: stand up a gateway, hand out client configs, route admin traffic through a tunnel. It works, but it’s its own infrastructure to run, patch, and trust. Tailscale is the version of that idea that actually fits how small teams operate: a mesh overlay network built on WireGuard, where every device you approve gets a stable address on your own private network, and nothing else can reach in. No gateway to maintain. No client configs to distribute by hand. Install it, authenticate, and the device is on the tailnet.
The plan for vor was simple to say: put the dashboard on the tailnet, keep it off the public internet, done. Getting there took three wrong turns first.
What doesn’t work (and why)
Plausible’s login page enforces three things at once before it lets you in: a CSRF check that the request’s Origin header matches what it expects, a session cookie marked Secure (which browsers refuse to send over plain HTTP), and a LiveView origin check that wants https. Its configured BASE_URL is the public HTTPS domain. Those three requirements collide with the two “obvious” ways to serve a dashboard privately:
| Attempt | What happened |
|---|---|
| Dashboard over plain HTTP on the tailnet | Login returns a flat 403. CSRF, the cookie, and LiveView can’t agree with each other over a connection with no HTTPS on it. |
| Dashboard over HTTPS on a bare Tailscale IP | Caddy’s internal certificate authority won’t issue a certificate for a bare IP address. TLS fails with an internal error before the connection even completes. |
Both of those are the kind of failure that looks like a bug in your setup, right up until you realize it’s actually a bug in the plan. Serving an app privately isn’t just about hiding the door. The app still needs to see a request that looks, from the inside, exactly like the one it was built to expect: the right hostname, the right protocol, a certificate that actually matches.
The fix was Tailscale Serve: it terminates a real HTTPS certificate on the droplet’s own tailnet hostname and forwards the request to a plain HTTP port that never leaves the machine. Caddy sits behind that, rewriting the upstream headers so Plausible sees exactly the request it wants:
header_up Host {$ANALYTICS_DOMAIN}
header_up X-Forwarded-Proto https
header_up Origin https://{$ANALYTICS_DOMAIN}
Scheme, host, certificate, and cookie all line up, because the app is told, truthfully, that it’s being reached the way it expects to be reached. It just happens that the only path there runs through a private mesh network instead of the open internet.
The split that actually matters
Here’s the part that isn’t just a Plausible quirk: not everything an analytics stack does can be private. The tracking script has to be public, because it loads in the browser of every visitor to every tracked site. The event-ingestion API has to be public too, for the same reason. There is no way around that, and pretending otherwise would just mean a broken product.
So the firewall doesn’t try to close everything. It draws one deliberate line: 80 and 443 stay open to the world, because that’s the only path a stranger’s browser ever needs. Everything that isn’t the tracking script or the event endpoint 404s at that same public address. The dashboard, the login, the settings: none of it exists at the public IP at all. It only exists at the droplet’s Tailscale address, reachable exclusively by devices already on the tailnet.
That’s the actual target of “zero-trust,” and it’s more precise than “everything is closed.” It’s “the only things open are the things that have to be, and the thing that matters most isn’t one of them.”
The port I haven’t closed yet
I could end there and it would sound tidier than it actually is, so here’s the honest part: SSH is still open on :22, restricted to a short allowlist of IPs rather than closed entirely. It’s a smaller surface than the open internet, but it isn’t the tailnet-only ideal, and a real security review of this exact stack flags it as the next thing worth doing: drop public :22 from the firewall and administer the box solely over Tailscale SSH, so the whole public surface narrows to 80 and 443, full stop.
I’m leaving that sentence in on purpose. A piece about closing management ports that quietly implies every port got closed would be the same kind of overclaim I’d flag in someone else’s infrastructure. This is the honest state of it: the part that mattered most (the admin dashboard, the actual keys to the kingdom) has zero public exposure today. The part that’s left is a known, tracked, smaller job, not a secret.
Why this is worth the setup
The instinct to skip all of this is understandable. A VPN, or a mesh network, or a reverse-proxy header rewrite is more setup than just opening a port and moving on. But the actual cost of the open port isn’t the setup you skipped. It’s every login attempt from an IP you’ll never recognize, forever, for as long as that port stays open. You’re not choosing between “some work now” and “no work.” You’re choosing between work you do once, deliberately, and a standing bet you keep making against the entire internet without ever deciding to.
A droplet with its management surface actually closed doesn’t announce itself. It just stops showing up in anyone’s scan. That’s the whole reward: not a feature, just the absence of a threat that used to be sitting there by default, open, waiting for whoever found it first.
Port 22 is next on the list. Everything that actually matters already made it off the internet.