If you’re running a homelab Kubernetes cluster — like my Raspberry Pi 5 cluster — you’ve probably hit the same wall: you want to expose a service to the internet, but you don’t want to poke holes in your firewall or deal with dynamic IP headaches. Cloudflare Tunnels solve this elegantly. Here’s how I wired it all up with cloudflared, HashiCorp Vault, ExternalSecrets, and FluxCD.
How It Works
Cloudflare Tunnels work by running a lightweight daemon (cloudflared) inside your cluster. This daemon opens an outbound connection to Cloudflare’s edge — so no inbound ports need to be opened. Traffic hits yourdomain.com, Cloudflare routes it through the tunnel, and cloudflared forwards it to your internal service.
Internet → Cloudflare Edge → cloudflared (in-cluster) → Internal Service
No public IP. No open ports. No reverse proxy configuration. Just a credential file and a config.
Step 1: Create the Tunnel
First, authenticate cloudflared and create a named tunnel:
cloudflared tunnel login
cloudflared tunnel create uclab
This generates a credentials JSON file (e.g. 1234-5678-abcd.json) that looks like:
{
"AccountTag": "123456789",
"TunnelSecret": "dadjelkqwjcz0xczjlkjqwlej",
"TunnelID": "1234-5678-abcd",
"Endpoint": ""
}
This file is the key to your tunnel — treat it like a password.
Step 2: Store the Credentials in Vault
Rather than committing the credentials file to Git, I store it in HashiCorp Vault and sync it into Kubernetes via ExternalSecrets:
vault kv put -mount=apps cloudflare-tunnel-uclab \
file="$(cat 1234-5678-abcd.json)"
Step 3: Sync the Secret with ExternalSecrets
An ExternalSecret resource pulls the credential from Vault and materializes it as a native Kubernetes secret:
# cloudflare-secret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: tunnel-credentials
namespace: cloudflared
spec:
refreshInterval: "15s"
secretStoreRef:
name: vault-backend-global
kind: ClusterSecretStore
target:
name: tunnel-credentials
creationPolicy: Owner
data:
- secretKey: credentials.json
remoteRef:
key: cloudflare-tunnel-uclab
property: file
Once applied, ExternalSecrets creates a tunnel-credentials Kubernetes secret containing credentials.json. You can verify it:
kubectl describe secrets tunnel-credentials -n cloudflared
Name: tunnel-credentials
Namespace: cloudflared
Type: Opaque
Data
====
credentials.json: 175 bytes
Step 4: Deploy cloudflared
The deployment mounts both the credentials secret and a ConfigMap that holds the tunnel configuration:
# cloudflare.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
spec:
selector:
matchLabels:
app: cloudflared
replicas: 2
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --config
- /etc/cloudflared/config/config.yaml
- run
livenessProbe:
httpGet:
path: /ready
port: 2000
failureThreshold: 1
initialDelaySeconds: 10
periodSeconds: 10
ports:
- containerPort: 2000
name: http-metrics
volumeMounts:
- name: config
mountPath: /etc/cloudflared/config
readOnly: true
- name: creds
mountPath: /etc/cloudflared/creds
readOnly: true
volumes:
- name: creds
secret:
secretName: tunnel-credentials
- name: config
configMap:
name: cloudflared
items:
- key: config.yaml
path: config.yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cloudflared
data:
config.yaml: |
tunnel: uclab
credentials-file: /etc/cloudflared/creds/credentials.json
metrics: 0.0.0.0:2000
no-autoupdate: true
ingress:
- hostname: uclab.dev
service: http://uclab.uclab.svc.cluster.local:80
- hostname: hello.example.com
service: hello_world
# Catch-all: return 404 for unmatched traffic
- service: http_status:404
A few things worth noting:
- Two replicas give you redundancy — if one pod dies, the tunnel stays up.
- The liveness probe hits
/ready, whichcloudflaredexposes only when it has an active connection to Cloudflare’s edge. This means Kubernetes will restart unhealthy tunnel pods automatically. - The ingress rules are evaluated top-to-bottom, with a catch-all
404at the end — same pattern as a reverse proxy config.
Step 5: Configure DNS
In the Cloudflare dashboard (or via CLI), point your hostname at the tunnel:
cloudflared tunnel route dns uclab uclab.dev
This creates a CNAME record pointing uclab.dev to your tunnel’s .cfargotunnel.com address. No A record, no IP — fully managed by Cloudflare.
The Full Flow
Git repo (FluxCD)
→ ExternalSecret → Vault → tunnel-credentials Secret
→ ConfigMap (tunnel config)
→ cloudflared Deployment
→ Cloudflare Edge
→ uclab.dev (public)
Everything is GitOps-friendly: the only thing outside of Git is the raw secret value in Vault. No credentials are ever committed, and rotating the secret is as simple as a vault kv put followed by ExternalSecrets picking it up within 15 seconds.