Cloudflare Tunnel: publishing my site from my homelab without opening a single port
My portfolio runs on a VM in my homelab, and no port is open at home. How the outbound tunnel works, the config, what I locked down around it and the traps I hit.
6 min read#cloudflare #homelab #networking #nextjs
This site runs on a virtual machine at my place, in my homelab. Yet no port is open on my router or my firewall: scan my IP address and nothing answers. I thought hosting a site at home meant forwarding port 443 to a server. Cloudflare Tunnel turns the connection around, and that is what I hadn't understood before setting it up.
The usual reflex
The classic way: a port 443 forward on the router, a reverse proxy, a Let's Encrypt certificate, and the home IP in the domain's DNS. It works, but then the whole internet can talk straight to the reverse proxy and to the app. The smallest flaw in either becomes a path into my network.
At home, the rule is simple: the only open port is the VPN's. I wanted to publish the portfolio without touching that, and to have it answer only on its domain name, never on the IP address.
The idea: the VM makes the call
cloudflared, Cloudflare's client, runs on the site's VM. On startup it opens outbound connections to Cloudflare's network itself: four connections over QUIC on port 7844, to Paris datacenters in my case. When someone asks for ambroise-krzanowski.fr, Cloudflare sends the request down one of these already-open connections, and cloudflared hands it to the app, which only listens on 127.0.0.1:3000.
visitor ──HTTPS──▶ Cloudflare
▲
║ outbound tunnel (7844),
║ opened by the VM
║
cloudflared ──▶ 127.0.0.1:3000So nothing listens on my public IP. The HTTPS certificate is Cloudflare's, nothing for me to renew. And the site's DNS doesn't hold my address: ambroise-krzanowski.fr points to the tunnel.
The catch: the domain's DNS must be run by Cloudflare, and the free plan is enough. I switched the nameservers at IONOS, which stays my registrar and my mail host.
The DNS zone: the orange cloud trap
On import, Cloudflare picked up my records… and set them to "proxied" by default. Including vpn (WireGuard), mc (Minecraft) and _dmarc. But Cloudflare's proxy only relays HTTP(S): left that way, they would have cut my VPN, and with it my own access to the homelab, the Minecraft server, and the DMARC check of my mail. Rule to remember: grey cloud ("DNS only") for anything that isn't a website.
The scan also missed my two DKIM records and one subdomain. I had listed the old zone with DNS queries before touching anything, and comparing line by line is how I spotted them. Without the DKIM records, my mail would have gone out with a signature nobody could check.
The tunnel, in four commands
Cloudflare offers to manage the tunnel from its Zero Trust dashboard. I preferred a locally managed tunnel: the config fits in a file I keep under version control.
# login prints a link: authorize the zone in the browser
cloudflared tunnel login
cloudflared tunnel create portfolio
cloudflared tunnel route dns portfolio ambroise-krzanowski.fr
cloudflared tunnel route dns portfolio www.ambroise-krzanowski.frThe ingress rules decide what gets through:
tunnel: <tunnel-id>
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: ambroise-krzanowski.fr
path: ^/api/(admin|auth)(/|$)
service: http_status:404
- hostname: www.ambroise-krzanowski.fr
path: ^/api/(admin|auth)(/|$)
service: http_status:404
- hostname: ambroise-krzanowski.fr
service: http://127.0.0.1:3000
- hostname: www.ambroise-krzanowski.fr
service: http://127.0.0.1:3000
- service: http_status:404The admin API never gets out: it answers 404 at Cloudflare, before reaching the server. The last rule answers 404 to any other hostname. The app also checks the Host header and answers 421 to anything but the domain: two curtains beat one. cloudflared tunnel ingress rule <url> tells which rule applies to a URL, handy to test before a restart.
What I locked down around it
A tunnel is not a firewall: a flaw in the app would still be exploitable through it. So:
- The VM sits in a DMZ on my pfSense. It may only go out to the firewall's DNS and time service, on TCP 80/443 for updates and on 7844 for the tunnel. Anything aimed at my private networks is blocked and logged.
- The site's service only has the loopback (
IPAddressDeny=anyandIPAddressAllow=localhostin systemd): even if compromised, the Node process cannot open a connection anywhere else. cloudflaredruns under its own account, not root. I wrote the systemd unit myself instead of usingcloudflared service install:
[Service]
User=cloudflared
ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run
NoNewPrivileges=yes
ProtectSystem=strict
CapabilityBoundingSet=- The account certificate is gone.
tunnel logindrops acert.pemthat can create tunnels and DNS records on the zone. It is only needed for the setup: once the tunnel existed, I deleted it. The VM only keeps the tunnel's credentials, which can only run that tunnel. - The site's admin never goes through the tunnel: it only answers on the loopback, through an SSH tunnel, with the VPN.
Two surprises
The visitor's IP. Behind the tunnel, every request comes from 127.0.0.1. Yet my contact form rate-limits by address. I sent a request with a fake X-Forwarded-For and captured what the app received:
X-Forwarded-For: 1.2.3.4,<my IP>
Cf-Connecting-Ip: <my IP>Cloudflare appends the real address at the end. My code takes the last item of the list: a forged header doesn't get around the limits.
A 500 that only existed in production. I wanted /admin to show a help page on the public domain. A rewrite (NextResponse.rewrite) worked locally and answered 500 behind the tunnel. In the logs:
Failed to proxy https://localhost:3000/acces-admin
Error: write EPROTONext.js sees its own origin as https://localhost: the protocol comes from Cloudflare, and it swaps 127.0.0.1 for localhost. Its router, though, compares against 127.0.0.1. So it took my rewrite for an external URL and relayed the request to itself… over TLS, on a port that speaks HTTP. My local test passed by luck: over HTTP, that relay works. A redirect fixed it:
// A redirect, not a rewrite: Next names its own origin
// https://localhost here but 127.0.0.1 when routing, so it
// would take a rewrite for an external URL and proxy it to
// itself over TLS (500 behind the tunnel)
if (admin === "page") return NextResponse.redirect(new URL(ADMIN_ACCESS_PAGE, SITE_URL), 307);The limits
Cloudflare terminates TLS: it sees all of the site's traffic in clear. For a public portfolio I accept that, and the admin doesn't go that way. The site also depends on Cloudflare for DNS and delivery. And the tunnel only hides the site: the VPN and Minecraft still point to my address.
I would do it all again: no open port, no certificate to manage, a VM that only talks to Cloudflare. What I mostly take away: list the old DNS zone before switching, because Cloudflare's import is neither complete nor careful.