Expose a home server behind CGNAT through a VPS over Tailscale (Caddy for HTTPS, HAProxy for TCP)
Without opening anything at home: put the home server and a public VPS on one tailnet, terminate HTTPS on the VPS with Caddy reverse_proxy to the server’s MagicDNS name, and forward raw TCP (game ports) with HAProxy in mode tcp. Do public-side rate limiting on the VPS, because the backend only sees the VPS’s tailnet IP.
- Status
- working
- Verified
- (0 days before this build)
- How
- In production since 2026: Oracle Cloud Always Free arm64 VM as edge, x86_64 home server on a CGNAT ISP line, serving HTTPS sites and a Minecraft server.
- Applies to
edge vmUbuntu 24.04 LTS (Oracle Cloud Always Free, arm64)home serverUbuntu 24.04 LTS, x86_64, behind CGNATtoolsTailscale, Caddy 2, HAProxy 2.8
Summary
Internet ──► VPS public IPv4 (Caddy :443, HAProxy :25565) ──tailnet──► home server
- The home router has no port forwards and the home server accepts no inbound traffic from the internet. Only the VPS has public ports.
- HTTPS: Caddy on the VPS gets certificates automatically and runs
reverse_proxy http://<home-magicdns-name>:<port>. - Non-HTTP TCP (game servers): HAProxy
mode tcpfrontend →server <tailnet-ip>:<port> check. - SSH to every machine stays on the
tailscale0interface only.
Edge: Caddy
media.example.com {
encode zstd gzip
reverse_proxy http://homeserver:8096
}
map.example.com {
encode zstd gzip
reverse_proxy http://homeserver:8100
}
homeserver resolves through Tailscale MagicDNS. On the home side, prefer having the app listen on 127.0.0.1 and publishing it to the tailnet with tailscale serve, or bind it to the tailscale IP. Avoid a broad 0.0.0.0 Docker port bind.
Edge: HAProxy for TCP
defaults
mode tcp
option tcplog
timeout connect 10s
timeout client 4h
timeout server 4h
frontend minecraft_tcp
bind *:25565
default_backend minecraft_backend
backend minecraft_backend
server home_mc 100.x.y.z:25565 check
The long client/server timeouts keep idle game sessions from being cut off. Validate the config before reloading: sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy.
Firewalls (both layers)
On Oracle Cloud, a port must be opened in the VCN security list and in the VM’s host firewall (ufw allow 443/tcp, etc.). Opening only one of them looks like a dead service.
Rate limiting has to happen on the edge
Behind the proxy, the home server’s sshd, fail2ban and application logs see only the edge’s tailnet IP. OpenSSH does not support the PROXY protocol, so it cannot learn the real client address. Do per-source limiting on the edge:
frontend sftp_tcp
bind *:2022
stick-table type ip size 100k expire 1h store conn_rate(1m),gpc0
tcp-request connection track-sc0 src
tcp-request connection reject if { sc0_get_gpc0 gt 0 }
tcp-request connection sc-inc-gpc0(0) if { sc0_conn_rate gt 10 }
tcp-request connection reject if { sc0_conn_rate gt 10 }
default_backend sftp_backend
A source that opens more than 10 connections per minute is flagged and rejected until it has been silent for 1 h.
When not to proxy: games that register their public IP
Some game servers (for example Don’t Starve Together via Klei’s lobby) advertise the public IP they register from. Behind a DNAT hop, the lobby lists an address that is not the one being forwarded, and players cannot join. Run such servers directly on a cloud VM with their UDP ports open there.
Not working / rejected
- frp (fatedier/frp) as the tunnel: it works (see the frp note), but it is one more daemon and token to manage when the machines are already on a tailnet.
- Tailscale subnet router + nginx-proxy-manager in Docker: every new port needed a VPS firewall change, a Docker port change and a rebuild, plus a UI entry. Plain Caddy/HAProxy config files are easier to keep in version control.
- Cloudflare Tunnel: HTTP(S) only on the free path; it does not carry arbitrary TCP game traffic for players without a client.
Search terms
CGNAT port forwarding alternativetailscale reverse proxy vpscaddy reverse_proxy tailscale magicdnshaproxy mode tcp minecraftself-host without port forwarding
Written and verified by Gyuwon Seol (seolcu). If this page solved your problem, you can support the work.
Published 2026-11-09 · last changed 2026-11-09 · Markdown source