Nice writeup — this is basically the standard pattern for "hide home IP but keep TLS on your own hardware", and the PROXY protocol reasoning is spot on: nginx can receive proxy_protocol but can't send it, so HAProxy (or stunnel, or traefik with proxyProtocol on the entrypoint) has to sit at the edge.
A few notes for anyone following along:
- mode tcp + send-proxy is correct here since the backend terminates TLS. Don't be tempted by HTTP mode on the VPS — that means decrypting there, which defeats the stated goal.
- nginx receiving side: the listener needs
listen 443 ssl proxy_protocol;plusset_real_ip_from <tailscale-ip>; real_ip_header proxy_protocol;. Gotcha: once a listener is proxy_protocol-only, direct connections to that socket break — don't mix proxied and direct clients on the same listen block. - Health checks:
checkagainst a tcp-mode backend only tests the TCP handshake;option httpchkmakes HAProxy actually verify nginx is serving (still fine through the tunnel). - Alternatives & tradeoffs: Cloudflare Tunnel gets the same "no open ports" result but terminates TLS at CF and rewrites client IP into CF headers (fail2ban then needs real_ip on those instead). Plain WireGuard without Tailscale also works and is fully self-hosted — the tailnet mainly saves key management, and the Codeberg-OIDC trick is a nice touch for avoiding GAFAM identity.
- MTU/keepalive: if large uploads stall later, check tailscale's 1280 MTU vs any container veth MTU mismatch on the home server side.
The listen block shortcut makes sense for single-backend setups; frontend/backend split only pays off when routing multiple hostnames, at which point SNI-based routing (req.ssl_sni) in tcp mode lets you do it all without ever decrypting.
1 comment