Cloudflare Zero Trust Tunnel: expose Kubernetes services without public IPs or inbound firewall rules
Deploy Cloudflare Tunnel (cloudflared) as a Kubernetes Deployment to expose internal ClusterIP services without opening inbound ports. Works identically on EKS, GKE, AKS, and on-prem.
Symptoms
You need to expose internal services but face constraints that make traditional ingress impractical:
# Internal services only accessible via ClusterIP
kubectl get svc
# NAME TYPE CLUSTER-IP PORT(S)
# my-api ClusterIP 10.96.0.15 8080/TCP
# admin-panel ClusterIP 10.96.0.22 3000/TCP
# No public IPs available or allowed by policy
# Firewall blocks ALL inbound traffic (zero-trust network)
# Cloud NAT or corporate proxy blocks traditional port-forwarding
# On-prem cluster behind CGNAT with no public routable address
Traditional solutions (LoadBalancer service, Ingress controller with external IP) require inbound firewall rules, public IPs, and TLS certificate management. In zero-trust environments or on-prem clusters behind NAT, these are not options.
Cause
The need for Cloudflare Tunnel arises from legitimate architectural constraints:
- Zero-trust network policies prohibit inbound connections to cluster nodes. All traffic must be initiated outbound.
- On-premises clusters behind CGNAT have no public IP addresses and cannot receive inbound connections from the internet.
- Cloud clusters in private subnets deliberately have no internet-facing load balancers. Exposing services requires additional infrastructure (NAT, bastion hosts, VPN).
- Multi-cluster or hybrid deployments where services span EKS, GKE, AKS, and bare metal. A unified access layer eliminates per-provider ingress configuration.
Cloudflare Tunnel solves all of these by establishing outbound-only persistent connections from your cluster to Cloudflare’s edge network. Traffic flows: Client -> Cloudflare Edge -> Tunnel -> cloudflared pod -> ClusterIP service.
Fix
Step 1: Create a tunnel via Cloudflare dashboard or CLI
# Install cloudflared CLI locally
brew install cloudflare/cloudflare/cloudflared
# Or: curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared
# Login to Cloudflare
cloudflared tunnel login
# Opens browser for OAuth - select your zone
# Create a new tunnel
cloudflared tunnel create k8s-production
# Tunnel credentials written to ~/.cloudflared/<TUNNEL_ID>.json
# Note the tunnel ID
export TUNNEL_ID="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
Step 2: Create the credentials Secret in Kubernetes
# Create namespace for tunnel infrastructure
kubectl create namespace cloudflared
# Create secret from the tunnel credentials file
kubectl create secret generic tunnel-credentials \
--namespace cloudflared \
--from-file=credentials.json=$HOME/.cloudflared/${TUNNEL_ID}.json
Step 3: Create the cloudflared ConfigMap with ingress rules
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: cloudflared-config
namespace: cloudflared
data:
config.yaml: |
tunnel: ${TUNNEL_ID}
credentials-file: /etc/cloudflared/creds/credentials.json
metrics: 0.0.0.0:2000
no-autoupdate: true
ingress:
- hostname: api.example.com
service: http://my-api.default.svc.cluster.local:8080
- hostname: admin.example.com
service: http://admin-panel.default.svc.cluster.local:3000
originRequest:
noTLSVerify: true
- service: http_status:404
EOF
The ingress rules map external hostnames to internal ClusterIP services using Kubernetes DNS names (<service>.<namespace>.svc.cluster.local).
Step 4: Deploy cloudflared as a Deployment
cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflared
labels:
app: cloudflared
spec:
replicas: 2
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:2024.6.1
args:
- tunnel
- --config
- /etc/cloudflared/config/config.yaml
- run
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
livenessProbe:
httpGet:
path: /ready
port: 2000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 2000
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: config
mountPath: /etc/cloudflared/config
readOnly: true
- name: creds
mountPath: /etc/cloudflared/creds
readOnly: true
volumes:
- name: config
configMap:
name: cloudflared-config
- name: creds
secret:
secretName: tunnel-credentials
EOF
Step 5: Create DNS records pointing to the tunnel
# Route hostnames to the tunnel
cloudflared tunnel route dns ${TUNNEL_ID} api.example.com
cloudflared tunnel route dns ${TUNNEL_ID} admin.example.com
# This creates CNAME records: api.example.com -> <TUNNEL_ID>.cfargotunnel.com
Step 6: Add Cloudflare Access policies for authentication
Protect services with identity-aware access policies:
# Via Cloudflare Zero Trust dashboard:
# Access > Applications > Add an Application > Self-hosted
# Or via API/Terraform:
cat <<EOF
# Terraform example - Cloudflare Access Application
resource "cloudflare_access_application" "admin_panel" {
zone_id = var.cloudflare_zone_id
name = "Admin Panel"
domain = "admin.example.com"
session_duration = "24h"
}
resource "cloudflare_access_policy" "admin_policy" {
zone_id = var.cloudflare_zone_id
application_id = cloudflare_access_application.admin_panel.id
name = "Allow team members"
precedence = 1
decision = "allow"
include {
email_domain = ["example.com"]
}
}
EOF
This adds SSO-based authentication in front of any exposed service without modifying the application itself.
Validation
After deployment, verify the tunnel is connected and services are accessible:
# 1. Verify cloudflared pods are running
kubectl get pods -n cloudflared
# Expected:
# NAME READY STATUS AGE
# cloudflared-7b9f4d-abc12 1/1 Running 2m
# cloudflared-7b9f4d-def34 1/1 Running 2m
# 2. Check tunnel status
cloudflared tunnel info ${TUNNEL_ID}
# Expected: Status: healthy, Connections: 4 (2 per replica)
# 3. Test external access to services
curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health
# Expected: 200
# 4. Verify Access policy blocks unauthenticated requests
curl -s -o /dev/null -w "%{http_code}" https://admin.example.com/
# Expected: 302 (redirect to Cloudflare Access login)
# 5. Check metrics endpoint
kubectl port-forward -n cloudflared deploy/cloudflared 2000:2000
curl http://localhost:2000/metrics | grep cloudflared_tunnel_active_streams
# Expected: cloudflared_tunnel_active_streams > 0
# 6. Verify no inbound firewall rules exist
# (cluster-specific - example for GKE)
gcloud compute firewall-rules list \
--filter="direction=INGRESS AND network=my-vpc" \
--format="table(name,direction,allowed)"
# Expected: no rules allowing inbound 80/443 from 0.0.0.0/0
Exposing services via public IPs and inbound firewall rules creates an attack surface that requires constant patching, DDoS protection, and certificate rotation. Cloudflare Tunnel eliminates all of that - no public IPs to scan, no ports to probe, no certificates to manage. The entire connection is outbound-only, authenticated at the edge, and encrypted end-to-end. This is not just convenience - it is a fundamentally smaller attack surface that works identically whether your cluster is in AWS, GCP, Azure, or a rack in your data centre.
Need zero-trust access to your Kubernetes services?
Book a free 30-minute call. We deploy Cloudflare Tunnel with Access policies across multi-cluster environments - cloud and on-prem alike.