The usual ways to expose Kubernetes services externally — LoadBalancer, NodePort, Ingress — all require something inbound: a cloud load balancer, an open port on the node, or an IP reachable from outside. On a home network or a machine behind NAT, none of those work without touching the router.
Cloudflare Tunnel (cloudflared) works the other way around.
How It Works
cloudflared runs inside the cluster as a Deployment. It makes an outbound HTTPS connection to Cloudflare’s edge. Cloudflare holds the connection open. When a request arrives for your domain, Cloudflare forwards it through that tunnel to cloudflared, which proxies it to the target service inside the cluster.
No inbound ports. No firewall rules. No public IP. The machine doesn’t need to be reachable from outside at all.
User → Cloudflare edge (HTTPS)
→ tunnel (outbound connection, held open by cloudflared pod)
→ cloudflared pod (inside cluster)
→ target service (ClusterIP, no external exposure)
Setup
You need a domain managed by Cloudflare and an API token with Tunnel and DNS edit permissions.
1. Create the tunnel via API
curl -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{
"name": "my-cluster",
"tunnel_secret": "'$(openssl rand -base64 32)'",
"config_src": "local"
}'
This returns a tunnel ID and the credentials JSON. Store the credentials JSON in a K8s Secret (or Vault ExternalSecret).
2. Create DNS records
For each service you want to expose, create a CNAME pointing to <tunnel-id>.cfargotunnel.com:
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{
"type": "CNAME",
"name": "grafana",
"content": "'${TUNNEL_ID}'.cfargotunnel.com",
"proxied": true
}'
3. Deploy cloudflared
The ConfigMap maps hostnames to internal cluster services:
apiVersion: v1
kind: ConfigMap
metadata:
name: cloudflared-config
namespace: cloudflared
data:
config.yaml: |
tunnel: <tunnel-id>
credentials-file: /etc/cloudflared/creds.json
ingress:
- hostname: grafana.yourdomain.com
service: http://grafana.monitoring.svc.cluster.local:80
- hostname: argocd.yourdomain.com
service: http://argocd-server.argocd.svc.cluster.local:80
- service: http_status:404
The Deployment mounts the credentials Secret and this ConfigMap:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflared
spec:
replicas: 2
template:
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- --config
- /etc/cloudflared/config.yaml
- run
volumeMounts:
- name: credentials
mountPath: /etc/cloudflared/creds.json
subPath: creds.json
readOnly: true
- name: config
mountPath: /etc/cloudflared/config.yaml
subPath: config.yaml
readOnly: true
volumes:
- name: credentials
secret:
secretName: cloudflared-credentials
- name: config
configMap:
name: cloudflared-config
Two replicas means Cloudflare has two tunnel connections. If one pod dies, traffic keeps flowing through the other.
What Cloudflare Handles
With proxied: true on the DNS records, Cloudflare terminates TLS at its edge. Your services behind the tunnel serve plain HTTP. No cert-manager, no Let’s Encrypt, no certificate rotation — Cloudflare handles it.
DDoS protection and WAF rules apply automatically. Cloudflare Access can sit in front of any tunnel hostname to require authentication before traffic reaches the cluster.
The Credentials Problem
The tunnel credentials are a JSON blob containing the tunnel ID, account tag, and tunnel secret. Passing a JSON blob through shell variable interpolation is error-prone — special characters in the JSON break argument parsing.
The clean solution: write the JSON to a temp file locally, copy it into the Vault pod with kubectl cp, then load it into Vault using vault kv put key=@/path/to/file. The @file syntax tells Vault to read the value from a file rather than interpreting it as a string argument.
Takeaway
cloudflared turns the connectivity model inside out. Instead of opening ports for inbound traffic, the cluster reaches out to Cloudflare and holds a connection. DNS + CNAME records route traffic into that connection. The cluster machine needs outbound HTTPS (port 443) and nothing else. Every home network allows that by default.