Note: This post was written with AI assistance (Claude). The opinions and experiences are entirely my own.
For most of my homelab's life I did what most people do: port-forward 443 on the router to whatever internal machine was hosting things, set up dynamic DNS because my ISP hands out a new IP every few weeks, and hope the site stays up for the ten minutes between an IP change and the DNS TTL expiring.
It worked, mostly. But it meant my home IP was public and attached to every reverse DNS lookup anyone ran against my domains. It meant the router firewall had holes in it. It meant every public service was one misconfigured nginx block away from something internal being reachable.
Cloudflare Tunnel fixed all of that.
What Cloudflare Tunnel Actually Does
The short version: instead of your server listening for inbound connections, cloudflared (the tunnel daemon) establishes an outbound connection to Cloudflare's edge. Traffic arrives at Cloudflare, travels through that established connection, and hits your service — no port forwarding, no exposed IP, no public-facing anything except a Cloudflare IP that belongs to Cloudflare, not you.
The longer version: cloudflared opens persistent outbound QUIC connections to multiple Cloudflare edge nodes simultaneously. When a request comes in for your domain, Cloudflare routes it through one of those connections to the running daemon, which proxies it to whatever internal service you've configured. Cloudflare handles TLS termination at the edge — no cert-manager, no certbot, no renewal cron jobs.
What you give up: all traffic flows through Cloudflare's network. For a personal homelab that's not a concern. For something handling sensitive data, it's worth thinking about.
Running It in Kubernetes
In my cluster, each externally-accessible app gets its own cloudflared deployment. One tunnel failure takes down one app, not everything. It costs a few extra pods but the isolation is worth it.
The deployment pattern is the same across every app:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: myapp
spec:
replicas: 2
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
nodeSelector:
nodeproxyworker: "true"
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- run
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: myapp-cloudflare-secret
key: TUNNEL_TOKEN
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 128Mi
Two replicas on dedicated nodeproxyworker nodes. The tunnel token lives in a Kubernetes Secret. --no-autoupdate stops cloudflared from attempting to update itself inside the container.
Setting Up a Tunnel
Creating the tunnel takes about two minutes in the Cloudflare dashboard:
- Networks → Tunnels → Create a tunnel — name it after the app, copy the token
- Public Hostnames — add a hostname pointing to the internal service address
For the internal service address, always use the full Kubernetes DNS name:
http://myapp.myapp.svc.cluster.local:3000
Using cluster DNS instead of a NodePort means routing works regardless of which node the cloudflared pod lands on, and the address doesn't change if pods reschedule.
The token goes into a Kubernetes Secret:
apiVersion: v1
kind: Secret
metadata:
name: myapp-cloudflare-secret
namespace: myapp
stringData:
TUNNEL_TOKEN: "your-token-here"
What I Replaced
Before Cloudflare Tunnel:
- Port 443 forwarded on the router to an nginx reverse proxy VM
- Dynamic DNS updating via a cron job hitting the Cloudflare API every few minutes
- Let's Encrypt certificates managed by certbot, renewal cron jobs, paths that changed on renewal
- My home IP associated with every public domain I run
After:
- No port forwards
- No dynamic DNS
- No certbot, no renewal cron jobs
- My home IP is not associated with any of my public services
- Cloudflare handles DDoS mitigation and bot filtering at the edge before traffic touches my network at all
The migration took a weekend. The result is a homelab where the only thing publicly visible is Cloudflare's IP ranges — which is exactly how it should be.