Notes on a zero-inbound VPS
I run several small apps on a single Hetzner VPS — a few internal tools, a couple of public sites. The goal I landed on: zero open inbound ports. The machine should be invisible to a scanner and still serve everything.
The trick is that both access paths are outbound.
Two outbound doors
┌─ Tailscale (outbound) ───────→ Caddy (bind 100.x:443) ──→ PRIVATE apps
VPS (0 ports in) ───┤
└─ Cloudflare Tunnel (outbound) → Caddy (127.0.0.1:8080) ──→ PUBLIC apps
- Tailscale dials out and joins my tailnet via NAT traversal — no inbound port required. Private apps bind to the Tailscale IP (
100.x), so they’re only routable from inside the tailnet. - Cloudflare Tunnel opens an outbound connection from the box to Cloudflare’s edge, which routes public hostnames back down that tunnel. No
:80/:443exposed.
Caddy stays the single internal router for both — it dispatches by host and terminates TLS for the private side (via DNS-01, since an HTTP-01 challenge can’t reach a 100.x address).
The firewall
With both doors outbound, the firewall becomes blunt and safe:
ufw default deny incoming
ufw default allow outgoing
ufw allow in on tailscale0 # critical — don't lock yourself out
ufw enable
The part nobody warns you about
You are sawing the branch you sit on. If the change that closes inbound also kills the channel you’re connected through, you’re stranded.
So two rules I now treat as non-negotiable:
- Keep an out-of-band break-glass. A web console (Hetzner’s, in my case) that survives the firewall and the tailnet both going down.
- Make the risky cutover self-healing. Apply, health-check, and auto-rollback if the check fails — don’t flip the switch blind.
A scanner sees nothing. I still reach everything. That’s the whole idea.