Built · field notes
Going live via Cloudflare Tunnel
How a domain, a VM, and a stubborn ISP became a live HTTPS site — every command, every manual step, every wrong turn, and the reason each one mattered.
Orientation
The finished picture
The goal was ordinary: point spencerlab.tech at a web server running in a VM at home, over HTTPS, without exposing anything that shouldn't be exposed. The path there was not ordinary — it ran head-first into the way the ISP hands out internet access, and the fix rewired the whole approach. Here is what ended up carrying the traffic:
Reference
The real values
Every command below uses these. Most homelab confusion comes down to losing track of which address is which.
- Domain
- spencerlab.tech
- Registrar + DNS
- Cloudflare
- Public IPv4 (shared)
- 72.136.x.x
- ISP WAN (DS-Lite)
- 192.0.0.2
- Windows host
- 192.168.1.228
- UbuntuServ (LabNet)
- 10.10.10.3
- ZeroTier
- private mgmt plane
- Cert expiry
- 2026-12-03 (auto)
Part 01 · naming
Domain & DNS delegation
The A records were entered, but nothing resolved. The domain was delegated to one Cloudflare zone (nameservers zara/kolton) while the records had been entered in a different zone (archer/naomi). Two zones, one domain — records in the wrong one. Querying a zone's own nameserver directly also caught a fat-fingered www record before it could waste an hour.
✕ The error
"Content for NS record is invalid. NS records at the apex must not overwrite assigned nameservers."
◆ The concept
Delegation lives at the registrar; records live in the zone. Which nameservers are authoritative for a domain is set at the registrar and stored at the registry — it is not a record edited inside the DNS zone. Apex NS records are never hand-edited; Cloudflare assigns them.
Part 02 · the server
The web server
sudo apt update && sudo apt install -y nginx sudo systemctl enable --now nginx # start now + on every boot curl -I http://localhost # prove it works LOCALLY first HTTP/1.1 200 OK
◆ Lesson
Each layer is proven locally before the next is added. Confirming Nginx answers on localhost means that when something breaks later, the cause is DNS / NAT / firewall — not the web server.
Part 03 · trust
The TLS certificate
A real Let's Encrypt certificate, issued with the DNS-01 challenge — because port 80 on the host was taken by IIS. DNS-01 proves control by writing a TXT record via the Cloudflare API: no open port, and it keeps working behind any NAT.
sudo apt install -y certbot python3-certbot-dns-cloudflare echo "dns_cloudflare_api_token = <TOKEN>" | sudo tee /root/.cloudflare.ini sudo chmod 600 /root/.cloudflare.ini sudo certbot certonly --dns-cloudflare \ --dns-cloudflare-credentials /root/.cloudflare.ini \ --dns-cloudflare-propagation-seconds 60 \ -d spencerlab.tech -d www.spencerlab.tech --agree-tos -m admin@email --no-eff-email Successfully received certificate.
▲ The fix
The first run failed on www with NXDOMAIN — Cloudflare hadn't published the challenge within the default 10 seconds. Raising --dns-cloudflare-propagation-seconds to 60 fixed it outright. Also: http2 on; is Nginx 1.25.1+; older Nginx wants listen 443 ssl http2;.
Part 04 · the dead end
The port-forward that couldn't work
The plan was textbook: forward the router's port 443 to the server. Firewall confirmed, host listening, origin answering — yet from outside, Cloudflare returned 522. The internal chain was perfect. The answer was one number: the router's WAN IP.
✕ WAN IP = 192.0.0.2
192.0.0.0/29 is reserved by RFC 7335 for DS-Lite. The ISP provides IPv6 and tunnels IPv4 through a shared carrier NAT. That "public" address is shared across many customers — there is no public IPv4 to forward to, so inbound port-forwarding is physically impossible.
◆ Lesson
The router's WAN IP is checked before attempting any port-forward. A reserved WAN IP (192.0.0.2, 100.64–100.127.x, 10.x) means carrier NAT and no inbound. An early "not CGNAT" check missed it — the tell was the WAN IP all along.
Part 05 · the answer
The pivot: Cloudflare Tunnel
A tunnel inverts the direction. Instead of the internet reaching in (impossible on DS-Lite), the server dials out to Cloudflare and holds the connection open. No inbound port, no public IP, no NAT to fight.
# on Linux it's 'cloudflared', never cloudflared.exe curl -L -o cloudflared.deb \ https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb sudo dpkg -i cloudflared.deb sudo cloudflared service install <LONG_TOKEN> # dashboard shows HEALTHY
Then, in the tunnel's Public Hostname config, route spencerlab.tech and www to HTTP → localhost:80.
◆ The 502 lesson
Point the tunnel at HTTP localhost:80, not HTTPS localhost:443. It connects over loopback inside the VM — traffic never leaves the machine — so plaintext is safe and Cloudflare supplies the public HTTPS. Leaving the type on HTTPS makes cloudflared attempt TLS against a plaintext port → an instant 502.
The climb
522 → 502 → 200
The last stretch was read entirely through HTTP status codes, fetched from outside the network. The error moving up the stack meant the path was getting closer, not further.
◆ Lesson
Verification comes from truly outside the network. Testing from the same LAN can mislead. Every green light was confirmed by fetching the public URL from an external vantage — the only test that proves what a visitor actually gets.
Posture
Why this is more secure than the plan
- Zero inbound ports open anywhere. The tunnel dials out; nothing dials in.
- The home IP is invisible. The public only ever talks to Cloudflare's edge.
- SSH, MySQL, Proxmox are unreachable from the internet by construction — no inbound path exists at all. The tunnel serves only
localhost:80, nothing else.
✓ Net result
A public HTTPS site with a smaller attack surface than the "correct" textbook port-forward — and it survives the ISP setup that made the textbook version impossible.