The problem: remote access without port forwarding
I wanted to be able to run an ArgoCD demo from anywhere, or troubleshoot the cluster from my phone off the LAN — without opening a port on my home router or exposing a dynamic IP to the whole internet. The classic solutions all have drawbacks:
- Port forwarding on the router: directly exposes ports on a residential IP. Security risk, dynamic IP, no automatic certificate.
- VPN: secure, but heavy to configure on every client device.
- Cloudflare Tunnel: a single outbound connection from the cluster to Cloudflare. No inbound port open. Cloudflare handles TLS, DDoS protection and DNS routing.
Cloudflare Tunnel is the solution I went with.
Architecture
Internet
│
▼
Cloudflare Edge (TLS terminated, DDoS protected)
│
│ Encrypted tunnel (outbound from the cluster)
▼
cloudflared (pod in the cluster)
│
│ Internal HTTP, port 80
▼
Traefik (Ingress Controller)
│
│ Routing by hostname
├──► gitea.olig-homelab-2025.win → Service gitea:3000
├──► code.olig-homelab-2025.win → Service code-server:8080
├──► claude.olig-homelab-2025.win → Service claude-webui:8080
└──► ...
Cloudflare terminates TLS on the internet side. The connection between Cloudflare and the cluster is encrypted through the tunnel. Inside the cluster, everything travels over HTTP on the internal Kubernetes network — acceptable for this threat model.
HTTP :80 en interne.
Deploying cloudflared
# cloudflared/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflared
spec:
replicas: 2
template:
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --config
- /etc/cloudflared/config.yaml
- run
volumeMounts:
- name: config
mountPath: /etc/cloudflared
readOnly: true
- name: creds
mountPath: /etc/cloudflared/creds
readOnly: true
volumes:
- name: config
configMap:
name: cloudflared-config
- name: creds
secret:
secretName: cloudflared-credentials
The deployment uses 2 replicas for redundancy — if one cloudflared pod crashes, the other keeps the tunnel up.
Tunnel configuration
# ConfigMap cloudflared-config
tunnel: <TUNNEL_ID>
credentials-file: /etc/cloudflared/creds/credentials.json
ingress:
- hostname: "*.olig-homelab-2025.win"
service: http://traefik.kube-system.svc.cluster.local:80
- service: http_status:404
The *.olig-homelab-2025.win wildcard routes all traffic to Traefik. Traefik then handles routing by Host header to the right service.
Traefik as Ingress Controller
Traefik v3.5.1 is installed through k3s’s HelmChart mechanism (--disable traefik at bootstrap turns off the default bundle, then a HelmChart CRD resource deploys the Helm version with the custom configuration). k3s’s ServiceLB stays active and handles Traefik’s LoadBalancer service — that is what creates the svclb-traefik-* pods on every node.
Key point: Cloudflare Tunnel routes to Traefik’s port 80 (the web entrypoint), not port 443. TLS is terminated by Cloudflare — Traefik does not need to manage certificates.
That has an important implication for Ingresses: do not add the websecure annotation on Ingress resources. A frequent mistake that leads to 404 page not found — Traefik is waiting for traffic on port 443, but it arrives on port 80.
# ✅ Correct — no websecure annotation
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gitea
namespace: gitea
spec:
ingressClassName: traefik
rules:
- host: gitea.olig-homelab-2025.win
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gitea
port:
number: 3000
# ❌ Incorrect — websecure forces port 443
metadata:
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
Cloudflare DNS
In the Cloudflare dashboard:
- Create a tunnel under “Zero Trust → Access → Tunnels”
- Add a CNAME record in DNS:
*.olig-homelab-2025.win → <tunnel-id>.cfargotunnel.com - The wildcard CNAME covers every subdomain with no per-subdomain DNS configuration
Proxy enabled (orange cloud): traffic goes through Cloudflare before reaching the tunnel. DDoS protection, cache and analytics included.
Extra security with Cloudflare Access
Every public subdomain goes through Cloudflare Access (SSO). For the most sensitive UIs (ArgoCD, Longhorn, Grafana) it is a non-negotiable line of defence: Cloudflare Zero Trust Access adds an authentication layer in front of the tunnel:
Request to argocd.olig-homelab-2025.win
│
▼
Cloudflare Access (identity check)
│ - Login with an authorised email (Cloudflare Access allowlist)
│ - JWT token injected into the request
▼
Tunnel → Traefik → ArgoCD UI
No Kubernetes middleware is needed — authentication is handled at the Cloudflare level, before the request reaches the cluster.
Update 2026-05-17 — Cloudflared adopted by ArgoCD
Context
cloudflared was running in the cluster as a Deployment created by hand at the very beginning of the homelab, outside GitOps. A critical workload — without it, no external access at all — yet invisible in the repo. It could drift silently: an unpatched image, a spec diverging from what we thought we had. Not ideal.
Two commits fixed that:
e345ac8 chore(cloudflared): bump 2026.3.0 -> 2026.5.0162bde3 feat(cloudflared): manage via ArgoCD (adopt existing deployment)
The operation in detail
1. Bumping to 2026.5.0 first
Before attaching ArgoCD, the image was updated to cloudflare/cloudflared:2026.5.0 and the new version was verified to run properly — tunnel up, Cloudflare connections confirmed. No point complicating the adoption by changing the image and the management model at the same time.
2. Creating the cloudflared/ folder in the repo
cloudflared/
├── 00-namespace.yaml
├── deployment.yaml # 2 replicas, inter-node anti-affinity
├── sealedsecret-creds.yaml # tunnel token sealed with Sealed Secrets
└── configmap.yaml # hostname → service mapping
The canonical deployment.yaml adds an anti-affinity rule to spread the two replicas across different nodes — which was not the case for the original Deployment:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: cloudflared
topologyKey: kubernetes.io/hostname
3. Adoption without downtime
Adopting an existing workload into GitOps is not the same thing as creating a new workload through GitOps. The subtlety: on the first sync, ArgoCD compares the declared manifest against the runtime state. The slightest difference triggers a rollout — exactly what you want to avoid on a component this critical.
The procedure followed:
# Capture the current state
kubectl get deploy cloudflared -n cloudflared -o yaml > current.yaml
# Strip the auto-generated fields
# Remove: metadata.{uid,resourceVersion,creationTimestamp,generation}, status
# Validate the server-side diff before creating the ArgoCD Application
kubectl diff -f cloudflared/deployment.yaml --server-side
The goal: zero diff between the declared YAML and the runtime state before the first sync. A label injected by a controller, a silent Kubernetes default — any divergence at all is enough to trigger an unwanted rollout. --dry-run=server -o yaml exposes those invisible diffs.
# ArgoCD Application with prune disabled as a precaution
syncPolicy:
automated:
prune: false # do not delete resources on the first sync
selfHeal: true
4. Validation
- No rolling update on the first sync: empty diff confirmed
- 2/2 pods
Ready - 4 active connections to Cloudflare datacenters (
fra,zrh) confirmed viacloudflared tunnel info
Lessons learned
http_status:404 at the end: always terminate the cloudflared config with a 404 fallback. Without it, cloudflared returns an opaque 502 error for unconfigured hostnames.
2 cloudflared replicas minimum: with a single replica, a pod restart (update, OOM kill) interrupts all external access for ~30 seconds. With 2 replicas, failover is transparent.
WebSocket timeouts: code-server uses WebSockets for the editor. Set the Cloudflare WebSocket timeout (Tunnel → Settings) to a high value (one hour) to avoid disconnections in the middle of work.
Update 2026-06 — The tunnel has its traps
Two lessons learned the hard way since this article was written:
QUIC is not guaranteed on every network. On my connection, the UDP 7844 data path is degraded somewhere in the router — QUIC connections register but the edge routes no requests at all. If your 502s coincide with a switch to QUIC, try --protocol http2 before looking any further (and vice versa).
A tunnel’s connectors are interchangeable — and that is a bomb if one of them is a ghost. A forgotten cloudflared on a device outside the cluster, registered on the cluster’s tunnel, cost me four days of intermittent 502s. The full story, false leads included: The phantom connector.