~/notes / remote-access-cgnat
Remote access to a homelab with no public IP
My ISP puts me behind CGNAT, so port forwarding was never an option. I set up two ways in instead, Tailscale for everyday use and a Cloudflare Tunnel for SSH on networks that block Tailscale, and neither one opens a port on my router.
$ cat goal
- Reach my self-hosted services from anywhere at the same
https://<service>.doof.questURLs I use at home, with valid certificates. - SSH into the Proxmox host from restrictive networks too, where Tailscale can be blocked.
- Zero inbound ports. Every connection starts from inside my network.
$ cat design
Tailscale is the main path. A small container acts as a subnet router, so any device on my tailnet can reach the home network. Tailscale's DNS settings send every query to my Pi-hole, which answers each service's name with the reverse proxy. The result is the same URLs and the same certificates, at home or on mobile data.
Cloudflare Tunnel is the fallback, for SSH only. A connector container keeps an outbound connection open to Cloudflare. Before anything reaches that tunnel, Cloudflare Access asks for an email one-time PIN, and only my address is allowed. Behind that, the Proxmox host only accepts SSH keys. On my laptop, cloudflared runs as the SSH proxy command, so it's still just ssh doof.
$ cat decisions
- Two paths, not one. Tailscale covers everything but can be blocked. The tunnel works anywhere HTTPS does, and I've tested it on a restricted network. Because it's reachable from anywhere, I kept it as narrow as possible: one service.
- The web UI stays off the tunnel. The Proxmox web UI is only reachable on the LAN or over Tailscale.
- Access before the route. I created the Access policy first and the tunnel route second, so the hostname was never reachable without the PIN in front.
- Exact email match. The policy matches my email address exactly, not "emails ending in", which would let in anyone on the same email domain.
- Pi-hole for all tailnet DNS. I chose this over split DNS so I also get ad blocking away from home. It needs "Override local DNS" turned on, or devices keep using their own resolver.
$ cat gotchas
- The wildcard caught the SSH name. At home, Pi-hole answers every
*.doof.questname with the reverse proxy, including the SSH hostname. The proxy only handles HTTP, so SSH failed at home but worked everywhere else. A more specific dnsmasq rule sends that one name to a public resolver:
I later dropped the wildcard for one record per service. The launch-day postmortem explains why.address=/doof.quest/<proxy> server=/<ssh-host>.doof.quest/1.1.1.1 - Password login was still on. I thought SSH was key-only, but the only
PasswordAuthenticationline was commented out, and OpenSSH defaults to yes.sshd -Tshows the settings actually in effect, not what the file seems to say. A drop-in insshd_config.d/fixed it. - The first match wins in
~/.ssh/config. An olderHost doofblock above the new one meantssh doofsilently skipped the tunnel.ssh -G doofshows what SSH will really use. - The new host block needed its own key. Without the
IdentityFileline, the tunnel worked but SSH fell back to asking for a password. - "DNS failure" that wasn't DNS.
apt updatein the new container couldn't resolve anything because the container had no IP address at all. Static IPs for containers go in Proxmox's network settings, not inside the guest, or they get overwritten on boot.
$ cat verify
dig +short <ssh-host>.doof.quest # Cloudflare IPs, even at home
ssh -G doof | grep -E "^(hostname|proxycommand)"
sshd -T | grep passwordauthentication # no
ssh -o PubkeyAuthentication=no doof # Permission denied (publickey)
$ cat lessons
- CGNAT pushed me to a better design. With no inbound ports, there's nothing on my home IP to scan.
- Check the effective config.
sshd -Tandssh -Ganswer "what is actually running", which isn't always what the file looks like. - Wildcards need exceptions. A catch-all DNS rule is convenient until one name needs to go somewhere else.