I've been using Cloudflare Tunnels for a while now. The shift happened as I became more security-conscious in my current role — when you spend your days thinking about minimizing entry points into a network professionally, you start looking at your homelab the same way. Every open port is a surface. Every exposed IP is something that can be probed, scanned, or targeted. The goal became: get that surface as small as possible.
Before tunnels, I had servers fully exposed to the internet with UFW as the only thing standing between them and the world. No dynamic DNS — I've always run servers, so I had static IPs — but that almost made it worse. A static IP attached to every domain, every reverse DNS lookup, every scan. Cloudflare Tunnel changed the equation entirely.
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 open ports, 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 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. The isolation is one reason — one tunnel failure takes down one app, not everything. But the bigger reason is portability. Each app is a self-contained unit: its own namespace, its own manifests, its own tunnel. If I want to move an app to a different cluster, I apply its manifests and it's running — the tunnel token doesn't change, the hostname doesn't change, nothing on the Cloudflare side needs touching. Nothing about the app knows or cares which cluster it's on. That makes the whole stack modular — easy to migrate with Flux, easy to replicate, easy to stand up a second cluster with a subset of apps without untangling shared dependencies.
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"
Why It's Worth It
The security case is the obvious one — no open ports, no exposed IP, Cloudflare's DDoS mitigation and bot filtering sitting in front of everything before traffic touches my network. That alone would be enough.
But the deployment experience is just as compelling. Spinning up a new service used to mean firewall rules, DNS records, certbot, nginx config, testing that the cert renewed correctly. Now it's: write the manifests, create a tunnel in the dashboard, add the hostname, push. The whole thing is live in minutes. Cloudflare handles the certificate, the IP, and the edge — I just point a hostname at an internal service address and it works.
This is where K3s makes it especially potent. Every new app in the cluster gets a cloudflared deployment alongside it — same pattern, same manifest structure, same two replicas on proxy nodes. The tunnel token goes in a Secret, Flux applies everything on push, and the service is publicly reachable before Keel has even had a chance to do its first poll. There's no host-level configuration to touch, no firewall rules to update, no node that needs to know the service exists. It's all just Kubernetes resources pointing at cluster-internal DNS names.
The result is a homelab where the only thing publicly visible is Cloudflare's IP ranges, deploying a new service takes minutes instead of an afternoon, and I'm not lying awake wondering what's sitting exposed on the internet.