Cloudflare Tunnels to Securely Expose Kubernetes Services

If you’re running a homelab Kubernetes cluster — like my Raspberry Pi 5 cluster — you’ve probably hit the same wall: you want to expose a service to the internet, but you don’t want to poke holes in your firewall or deal with dynamic IP headaches. Cloudflare Tunnels solve this elegantly. Here’s how I wired it all up with cloudflared, HashiCorp Vault, ExternalSecrets, and FluxCD.

cloudflared-tunnel

How It Works

Cloudflare Tunnels work by running a lightweight daemon (cloudflared) inside your cluster. This daemon opens an outbound connection to Cloudflare’s edge — so no inbound ports need to be opened. Traffic hits yourdomain.com, Cloudflare routes it through the tunnel, and cloudflared forwards it to your internal service.

Internet → Cloudflare Edge → cloudflared (in-cluster) → Internal Service

No public IP. No open ports. No reverse proxy configuration. Just a credential file and a config.


Step 1: Create the Tunnel

First, authenticate cloudflared and create a named tunnel:

cloudflared tunnel login
cloudflared tunnel create uclab

This generates a credentials JSON file (e.g. 1234-5678-abcd.json) that looks like:

{
  "AccountTag": "123456789",
  "TunnelSecret": "dadjelkqwjcz0xczjlkjqwlej",
  "TunnelID": "1234-5678-abcd",
  "Endpoint": ""
}

This file is the key to your tunnel — treat it like a password.


Step 2: Store the Credentials in Vault

Rather than committing the credentials file to Git, I store it in HashiCorp Vault and sync it into Kubernetes via ExternalSecrets:

vault kv put -mount=apps cloudflare-tunnel-uclab \
  file="$(cat 1234-5678-abcd.json)"

Step 3: Sync the Secret with ExternalSecrets

An ExternalSecret resource pulls the credential from Vault and materializes it as a native Kubernetes secret:

# cloudflare-secret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: tunnel-credentials
  namespace: cloudflared
spec:
  refreshInterval: "15s"
  secretStoreRef:
    name: vault-backend-global
    kind: ClusterSecretStore
  target:
    name: tunnel-credentials
    creationPolicy: Owner
  data:
    - secretKey: credentials.json
      remoteRef:
        key: cloudflare-tunnel-uclab
        property: file

Once applied, ExternalSecrets creates a tunnel-credentials Kubernetes secret containing credentials.json. You can verify it:

kubectl describe secrets tunnel-credentials -n cloudflared
Name:         tunnel-credentials
Namespace:    cloudflared
Type:         Opaque
Data
====
credentials.json:  175 bytes

Step 4: Deploy cloudflared

The deployment mounts both the credentials secret and a ConfigMap that holds the tunnel configuration:

# cloudflare.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
spec:
  selector:
    matchLabels:
      app: cloudflared
  replicas: 2
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:latest
          args:
            - tunnel
            - --config
            - /etc/cloudflared/config/config.yaml
            - run
          livenessProbe:
            httpGet:
              path: /ready
              port: 2000
            failureThreshold: 1
            initialDelaySeconds: 10
            periodSeconds: 10
          ports:
            - containerPort: 2000
              name: http-metrics
          volumeMounts:
            - name: config
              mountPath: /etc/cloudflared/config
              readOnly: true
            - name: creds
              mountPath: /etc/cloudflared/creds
              readOnly: true
      volumes:
        - name: creds
          secret:
            secretName: tunnel-credentials
        - name: config
          configMap:
            name: cloudflared
            items:
              - key: config.yaml
                path: config.yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: cloudflared
data:
  config.yaml: |
    tunnel: uclab
    credentials-file: /etc/cloudflared/creds/credentials.json
    metrics: 0.0.0.0:2000
    no-autoupdate: true

    ingress:
      - hostname: uclab.dev
        service: http://uclab.uclab.svc.cluster.local:80

      - hostname: hello.example.com
        service: hello_world

      # Catch-all: return 404 for unmatched traffic
      - service: http_status:404

A few things worth noting:

  • Two replicas give you redundancy — if one pod dies, the tunnel stays up.
  • The liveness probe hits /ready, which cloudflared exposes only when it has an active connection to Cloudflare’s edge. This means Kubernetes will restart unhealthy tunnel pods automatically.
  • The ingress rules are evaluated top-to-bottom, with a catch-all 404 at the end — same pattern as a reverse proxy config.

Step 5: Configure DNS

In the Cloudflare dashboard (or via CLI), point your hostname at the tunnel:

cloudflared tunnel route dns uclab uclab.dev

This creates a CNAME record pointing uclab.dev to your tunnel’s .cfargotunnel.com address. No A record, no IP — fully managed by Cloudflare.


The Full Flow

Git repo (FluxCD) 
  → ExternalSecret → Vault → tunnel-credentials Secret
  → ConfigMap (tunnel config)
  → cloudflared Deployment
      → Cloudflare Edge
          → uclab.dev (public)

Everything is GitOps-friendly: the only thing outside of Git is the raw secret value in Vault. No credentials are ever committed, and rotating the secret is as simple as a vault kv put followed by ExternalSecrets picking it up within 15 seconds.

my DevOps Odyssey

“Σα βγεις στον πηγαιμό για την Ιθάκη, να εύχεσαι να ‘ναι μακρύς ο δρόμος, γεμάτος περιπέτειες, γεμάτος γνώσεις.” - Kavafis’ Ithaka.



How to expose a Kubernetes service to the internet without opening firewall ports, using Cloudflare Tunnels, ExternalSecrets, and Vault.

2026-03-06

Series:lab

Categories:Kubernetes

Tags:#cloudflared, #tunnels, #k3s, #lab


Cloudflare Tunnels to Securely Expose Kubernetes Services: