← Built

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:

WHAT FAILED WHAT WORKS Internet a visitor Nokia router WAN 192.0.0.2 DS-Lite: no inbound IPv4 UbuntuServ 10.10.10.3 · never reached Internet a visitor https Cloudflare edge TLS · hides the IP outbound tunnel dialed OUT — no inbound port cloudflared → Nginx :80
The whole story in one frame: inbound port-forwarding (top) can never reach the server because the ISP runs DS-Lite carrier NAT — there is no public IPv4 to forward to. The tunnel (bottom) has the server dial out to Cloudflare, so nothing needs to come in.
200 OKHTTPS everywhere 0 inbound portshome IP hidden auto-renewing cert

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

UbuntuServ · bash
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.

UbuntuServ · bash
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.

UbuntuServ · bash
# 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.

522
Can't reach the origin at all. Cloudflare's connection to the home IP timed out — the DS-Lite wall.
502
Now routing through the tunnel — but cloudflared couldn't get a clean response, from the HTTPS-vs-HTTP origin mismatch.
200
Live. The page returns from the public internet, valid cert, through the tunnel.

◆ 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.