Cloudflare Tunnel on Kubernetes: Public Services Without Port Forwarding
Cloudflare's official Kubernetes deployment guide takes ten minutes. A Deployment manifest, a tunnel token, outbound-only connectivity to Cloudflare's edge. No open firewall ports, no port forwarding, no public IPs. The tutorial even acknowledges that "each cloudflared replica/pod can reach all Kubernetes services in the cluster." Then it moves on.
That last sentence is the problem. An unrestricted cloudflared pod has the same network reach as any pod in the cluster. It can resolve and connect to any Service in any namespace: your PostgreSQL database, your Grafana dashboards, your Vault secrets API, your GitLab instance with CI/CD tokens. If an attacker exploits a vulnerability in one of the public services the tunnel exposes, the cloudflared pod becomes their pivot point. From there, standard Kubernetes service discovery hands them a map of every internal service.
The traffic flow makes this concrete:
Internet → Cloudflare Edge → QUIC tunnel → cloudflared pod → any K8s Service (without network policy)
Without a network policy, that last arrow has no restrictions. The tunnel eliminates the external attack surface by replacing inbound connections with outbound ones, but it creates an internal one that most tutorials never address.
This cluster runs Cloudflare Tunnel with 2 replicas across separate nodes, 8 persistent QUIC connections to the Cloudflare edge, and a CiliumNetworkPolicy that restricts the tunnel to exactly 5 namespaces and 6 ports. Everything else (GitLab, Grafana, Longhorn, Vault, the NAS) is unreachable from the tunnel pod. The security boundary is the interesting part, not the tunnel setup.
Cloudflare Tunnel Deployment
Seven manifests live in manifests/cloudflare/: namespace, deployment, PDB, service, ServiceMonitor, ExternalSecret, and CiliumNetworkPolicy. No Helm chart. Cloudflare publishes an official chart, but raw manifests make the security controls visible and match the pattern used throughout this cluster.
The tunnel runs in remotely-managed mode. Ingress routes (which public hostname maps to which backend service) are configured in the Cloudflare Zero Trust dashboard, not in Kubernetes manifests. The Deployment handles the connector; Cloudflare's control plane handles routing.
With a network policy in place, the traffic flow is locked down:
Internet → Cloudflare Edge → QUIC → cloudflared pod → allowed K8s Services only
✗ GitLab, Grafana, Vault, NAS (blocked)
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflare
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
template:
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: cloudflared
image: cloudflare/cloudflared:2026.1.1
args:
- tunnel
- --no-autoupdate
- --metrics
- 0.0.0.0:2000
- run
- --token
- $(TUNNEL_TOKEN)
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: cloudflared
topologyKey: kubernetes.io/hostname
Three configuration choices matter for security and observability.
automountServiceAccountToken: false prevents the pod from accessing the Kubernetes API. cloudflared has no need for cluster credentials; mounting the service account token would hand an attacker a direct path to the API server if the pod were compromised.
--no-autoupdate disables cloudflared's built-in auto-update mechanism. In a container, the image tag is the source of truth for versioning. Letting the binary update itself inside a read-only filesystem would fail anyway, but being explicit matters.
--metrics 0.0.0.0:2000 pins the Prometheus metrics endpoint to port 2000. Without this flag, cloudflared picks a random port between 20241 and 20245, which breaks ServiceMonitor configurations. The Cloudflare docs mention this behavior, but most tutorials skip the flag entirely.
The anti-affinity is required, not preferred. On a 3-node cluster, this guarantees the two pods land on different nodes. If a node goes down, one pod survives. The PodDisruptionBudget (minAvailable: 1) ensures at least one pod stays running during voluntary disruptions like node drains and cluster upgrades.
What eight QUIC connections look like
Each cloudflared replica opens 4 QUIC connections to 4 different Cloudflare edge servers across at least 2 data centers. Two replicas produce 8 connections total. The tunnel logs show which edge locations each connection registers to:
INF Registered tunnel connection connIndex=0 connection=... event=0 ip=198.41.192.57 location=sin12 protocol=quic
INF Registered tunnel connection connIndex=1 connection=... event=0 ip=198.41.200.13 location=sin14 protocol=quic
INF Registered tunnel connection connIndex=2 connection=... event=0 ip=198.41.192.167 location=mnl01 protocol=quic
INF Registered tunnel connection connIndex=3 connection=... event=0 ip=198.41.200.113 location=sin12 protocol=quic
With the cluster in the Philippines, connections land on Manila (mnl01) and Singapore (sin12, sin14) edge PoPs. Cloudflare's Anycast routing selects the nearest data centers automatically. Two connectors across diverse edge locations means a localized Cloudflare outage at one data center doesn't take down the tunnel.
The HA model is active/active, not load-balanced. Both connectors serve traffic simultaneously. Cloudflare routes each request to the geographically closest healthy connector. If one pod disappears, traffic shifts to the other with no DNS propagation delay.
During testing, one of the 8 connections cycled roughly every 4 minutes (context cancellation, reconnect, re-register). This is normal QUIC behavior when an individual edge server has transient issues. The other 7 connections absorb the traffic. Monitoring confirmed zero request failures during these cycles.
The connection logs also reveal that cloudflared negotiates X25519MLKEM768 as the first curve preference, a hybrid post-quantum key exchange combining X25519 with ML-KEM (Kyber). Post-quantum TLS on the tunnel, out of the box.
The Security Boundary
The CiliumNetworkPolicy is the core of this deployment. Without it, cloudflared can reach every pod in every namespace through standard Kubernetes service discovery. With it, the tunnel is restricted to exactly the services it needs to proxy.
The egress policy has two complementary mechanisms:
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: cloudflared-egress
namespace: cloudflare
spec:
endpointSelector:
matchLabels:
app: cloudflared
egress:
# DNS
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
# Cloudflare Edge (block all private IPs)
- toCIDRSet:
- cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
toPorts:
- ports:
- port: "443"
protocol: TCP
- port: "7844"
protocol: TCP
- port: "7844"
protocol: UDP
# Ghost prod (blog + analytics)
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: ghost-prod
toPorts:
- ports:
- port: "2368"
protocol: TCP
- port: "3000"
protocol: TCP
# Portfolio (staging + prod)
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: portfolio-staging
- matchLabels:
k8s:io.kubernetes.pod.namespace: portfolio-prod
toPorts:
- ports:
- port: "80"
protocol: TCP
# Invoicetron prod
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: invoicetron-prod
toPorts:
- ports:
- port: "3000"
protocol: TCP
# Uptime Kuma
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: uptime-kuma
toPorts:
- ports:
- port: "3001"
protocol: TCP
# Everything else: implicit deny
Mechanism 1: CIDR filtering for external traffic. The toCIDRSet rule allows 0.0.0.0/0 (all IPs) except three RFC 1918 ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. This lets cloudflared reach Cloudflare's public edge servers on ports 443 and 7844 while blocking all private infrastructure: the NAS at 10.10.30.4, the OPNsense router, the switches, anything on the home network.
Mechanism 2: endpoint selectors for in-cluster traffic. The toEndpoints rules use namespace labels to allow traffic to specific namespaces on specific ports. Only 5 namespaces are reachable for proxied traffic (plus kube-system for DNS resolution): ghost-prod, portfolio-staging, portfolio-prod, invoicetron-prod, and uptime-kuma. The 18+ other namespaces (gitlab, monitoring, longhorn-system, vault, home, etc.) are blocked by implicit deny.
A subtle but important detail: Cilium evaluates CIDR rules and endpoint rules differently. The except 10.0.0.0/8 block covers the pod CIDR range (10.0.x.x in this cluster), but Cilium never evaluates CIDR rules for pod-to-pod traffic. When both endpoints are managed by Cilium, it uses identity-based matching instead. The toEndpoints selectors are what control which pods cloudflared can reach, not the CIDR rule. This is documented in Cilium's policy language reference and confirmed on the live cluster: cloudflared reaches allowed pods despite their IPs falling within the excepted CIDR range.
What the tunnel can and cannot reach
| Destination | Allowed | Port | Why |
|---|---|---|---|
| blog.rommelporras.com (Ghost) | Yes | 2368 | Blog content |
| blog-api.rommelporras.com (TrafficAnalytics) | Yes | 3000 | Web analytics proxy |
| www.rommelporras.com (Portfolio) | Yes | 80 | Portfolio site |
| status.rommelporras.com (Uptime Kuma) | Yes | 3001 | Public status page |
| invoicetron.rommelporras.com | Yes | 3000 | Invoice app |
| gitlab.k8s.rommelporras.com | No | - | Source code, CI/CD credentials |
| grafana.k8s.rommelporras.com | No | - | Dashboards, query access |
| longhorn.k8s.rommelporras.com | No | - | Storage management UI |
| adguard.k8s.rommelporras.com | No | - | DNS admin |
| Vault API | No | - | Secrets store |
| NAS (10.10.30.4) | No | - | File storage, backups |
Internal services are accessed via Tailscale subnet routing instead. The tunnel handles public traffic; Tailscale handles private access. Two separate network paths, zero overlap.
Live cluster evidence
Two pods on separate nodes, zero restarts:
$ kubectl get pods -n cloudflare -o wide
NAME READY STATUS RESTARTS AGE NODE
cloudflared-7b4f8c9d6-k2x4m 1/1 Running 0 6d14h k8s-cp1
cloudflared-7b4f8c9d6-r9w3n 1/1 Running 0 6d14h k8s-cp3
Both CiliumNetworkPolicies enforced:
$ kubectl get ciliumnetworkpolicies -n cloudflare
NAME AGE
cloudflared-egress 55d
cloudflared-ingress 4d
The PDB confirms one disruption is allowed (one pod can go down, the other keeps serving):
$ kubectl get pdb -n cloudflare
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
cloudflared 1 N/A 1 55d
Missing IPv6 coverage
The egress CIDR rule only covers IPv4. There's no ::/0 rule blocking IPv6 private ranges. On a homelab VLAN where IPv6 isn't routed to the cluster, this is a non-issue. On a dual-stack cluster with IPv6 connectivity, cloudflared could theoretically egress over IPv6 without restrictions. Add an IPv6 CIDR rule if your cluster has IPv6 enabled.
Defense in Depth at the Edge
CiliumNetworkPolicy controls what the tunnel can reach inside the cluster. Cloudflare WAF and Access policies control what reaches the tunnel from the internet.
WAF custom rules
Three rules protect the Ghost blog admin panel:
| Priority | Rule | Action |
|---|---|---|
| 1 | Allow RSS feed (/rss/) |
Skip remaining rules + Bot Fight Mode |
| 2 | Allow Content API (/ghost/api/content) |
Skip remaining rules |
| 3 | Block Ghost admin (/ghost) |
Block |
The ordering matters. Rule 3 blocks all paths starting with /ghost, which would catch the Content API and RSS feed if they weren't explicitly allowed first. WAF rules evaluate sequentially; the Block action terminates evaluation for that request.
Why WAF instead of Cloudflare Access for Ghost admin protection? Access policies use path-based matching, but in testing the path precedence was unreliable. A policy blocking /ghost sometimes interfered with /ghost/api/content requests, even with an explicit allow. WAF custom rules are deterministic: strict sequential evaluation, no ambiguity.
Rule 1 also skips Bot Fight Mode for the RSS feed path. Bot Fight Mode on the free tier blocks all cloud provider IPs globally, with no path exceptions. This broke a GitHub Actions workflow that fetches the RSS feed to populate the GitHub profile README. Disabling Bot Fight Mode globally and using WAF rules for selective bot protection was the only workaround on the free tier.
Access policies
| Application | Protection | Method |
|---|---|---|
| Uptime Kuma status page | Block admin paths (/dashboard, /settings) | Block Everyone |
| Invoicetron | Require authentication | Email OTP |
Email OTP (One-time PIN) works on the free Cloudflare tier. Users requesting access receive a code by email; Cloudflare validates the OTP before allowing the request through the tunnel. For Invoicetron, only two approved email addresses are whitelisted.
Monitoring
A ServiceMonitor scrapes cloudflared every 30 seconds on port 2000, feeding into the same kube-prometheus-stack that powers the cluster's alerting pipeline. Key metrics:
| Metric | What it measures |
|---|---|
cloudflared_tunnel_total_requests |
Total requests proxied through the tunnel |
cloudflared_tunnel_request_errors |
Failed proxy requests |
cloudflared_tunnel_response_by_code |
Response code distribution (2xx, 4xx, 5xx) |
cloudflared_tunnel_concurrent_requests_per_tunnel |
Active concurrent requests |
Alert rules
Two PrometheusRules catch tunnel health issues:
CloudflareTunnelDegraded (warning, 5 minutes): fewer than 2 pods healthy. One pod can handle all traffic, but you've lost redundancy. Fix whatever is preventing the second pod from scheduling.
CloudflareTunnelDown (critical, 2 minutes): zero pods healthy. All public services are unreachable. This fires to Discord #incidents and email simultaneously, using the same alerting pipeline that handles cluster-wide incidents.
Both use the up{job="cloudflared"} metric from the ServiceMonitor.
Secret management
The tunnel token comes from HashiCorp Vault via External Secrets Operator, following the same pattern used for all 30 ExternalSecrets across the cluster. The ExternalSecret refreshes hourly, keeping the Kubernetes Secret in sync with Vault. If the token is rotated, the pod needs a restart (kubectl rollout restart deployment/cloudflared -n cloudflare) since environment variables are injected at pod creation and don't update dynamically. The rolling update strategy and PDB ensure zero downtime during the restart.
Gotchas
HTTP, not HTTPS to Ghost. cloudflared proxies to Ghost using HTTP on port 2368. Ghost doesn't serve TLS directly; Gateway API terminates TLS for internal cluster traffic. If you configure the tunnel to use HTTPS, cloudflared attempts TLS negotiation with Ghost, which fails. Cloudflare handles HTTPS on the public side; the tunnel talks plain HTTP to the backend. This applies to any backend where TLS terminates at the ingress layer rather than the application.
Network policy updates per service. When Ghost analytics (TrafficAnalytics) was added, port 3000 needed to be added to the ghost-prod egress rule. Without it, cloudflared returned 502 Bad Gateway. The CiliumNetworkPolicy doesn't break silently. It breaks loudly and immediately.
Restricted PSS needs a sysctl tweak. cloudflared runs as UID 65532 (nobody), read-only root filesystem, all capabilities dropped, seccomp enabled. It complies with the restricted PSS profile out of the box. However, the pod spec includes net.ipv4.ping_group_range: "65532 65532" so the non-root user can send ICMP pings, which cloudflared uses for connectivity checks.
Free SSL only covers single-level subdomains. Cloudflare's Universal SSL certificate covers *.rommelporras.com but not *.sub.rommelporras.com. When adding Ghost analytics, analytics.blog.rommelporras.com failed with a TLS handshake error. Flattening it to blog-api.rommelporras.com fixed it immediately. Every hostname exposed through the tunnel must be a single-level subdomain of the apex domain, or it won't work on the free tier.
Bot Fight Mode blocks GitHub Actions. Bot Fight Mode on the free tier blocks all cloud provider IPs globally, with no path-specific exceptions. This broke a GitHub Actions workflow that fetches the RSS feed to populate the GitHub profile README. The workaround: disable Bot Fight Mode globally and use WAF custom rules to block bots selectively by path.
What's Still Missing
No Grafana dashboard for tunnel metrics. The ServiceMonitor feeds data to Prometheus, but there's no dedicated dashboard visualizing request rates, error rates, or connection status. Uptime Kuma monitors the public endpoints externally, but Prometheus Blackbox probes testing end-to-end through the tunnel would close the observability gap.
What This Costs
Every piece of this Cloudflare integration runs on the free tier. Zero cost for the tunnel, DNS, WAF rules, Access policies, and Universal SSL. Kubernetes compute is the only expense: 200m CPU and 128Mi memory across both pods at idle.
Within the cluster, the cloudflare namespace is one of the most tightly locked down. Two CiliumNetworkPolicies (egress + ingress), restricted PSS, no service account token, read-only filesystem. It sits within a cluster running 125 CiliumNetworkPolicies across 23 namespaces, all enforcing the same default-deny posture.
Setting up the tunnel genuinely takes ten minutes. Making it production-grade takes longer, but the work is in the network policy, not the tunnel configuration. If you're running Cloudflare Tunnel on Kubernetes without network policies restricting what the tunnel pod can reach, you've eliminated the external attack surface while leaving the internal one wide open.