~/notes / remote-access-cgnat

#howto · #networking #security #dns · 2026-09-16

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.quest URLs 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

Two remote access paths into the homelab Path one: a device on Tailscale reaches the home network through a subnet router, uses Pi-hole for DNS, and reaches services through the reverse proxy. Path two: an SSH client goes through Cloudflare Access, which requires an email one-time PIN, then through an outbound-only tunnel to a connector container, which forwards to the Proxmox host's SSH with key-only login. Both cross the CGNAT boundary using outbound connections only. everyday · all services laptop / phone Tailscale on WireGuard subnet router DNS → Pi-hole reverse proxy wildcard TLS CGNAT no inbound ports internet home fallback · SSH only ssh doof any network Cloudflare Access email + one-time PIN tunnel connector dials out to Cloudflare Proxmox sshd key-only
Two paths in, both started from inside the network.

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.quest name 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:
    address=/doof.quest/<proxy>
    server=/<ssh-host>.doof.quest/1.1.1.1
    I later dropped the wildcard for one record per service. The launch-day postmortem explains why.
  • Password login was still on. I thought SSH was key-only, but the only PasswordAuthentication line was commented out, and OpenSSH defaults to yes. sshd -T shows the settings actually in effect, not what the file seems to say. A drop-in in sshd_config.d/ fixed it.
  • The first match wins in ~/.ssh/config. An older Host doof block above the new one meant ssh doof silently skipped the tunnel. ssh -G doof shows what SSH will really use.
  • The new host block needed its own key. Without the IdentityFile line, the tunnel worked but SSH fell back to asking for a password.
  • "DNS failure" that wasn't DNS. apt update in 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 -T and ssh -G answer "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.
lab up — · 9 notes ·