Cloudflare Zero Trust Kubernetes networking

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.

·
Expose internal Kubernetes services to the internet without opening any inbound firewall rules, without provisioning public IPs, and without managing TLS certificates. Cloudflare Tunnel creates outbound-only connections from your cluster to Cloudflare's edge. This works identically on EKS, GKE, AKS, and bare-metal clusters.

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.

 

Jerzy Kopaczewski

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.