This note originally lived inside a very specific service setup, but the reusable part was the tunnel pattern.

The real question was: how do I expose a TCP service from Kubernetes through Cloudflare Tunnel without publishing the raw internal endpoint directly?

1. Create the Tunnel Credentials

The source workflow started from a machine with cloudflared installed:

1
2
cloudflared tunnel login
cloudflared tunnel create <tunnel-name>

That produces:

  • an origin certificate
  • a tunnel credentials JSON file

Both should be treated as sensitive material.

2. Store the Credentials in Kubernetes

The next practical step was to put those files into Kubernetes secrets:

1
2
3
4
5
6
7
8
9
kubectl create namespace cloudflared

kubectl create secret generic tunnel-credentials \
  --from-file=credentials.json=/path/to/tunnel-credentials.json \
  -n cloudflared

kubectl create secret generic cloudflare-cert \
  --from-file=/path/to/cert.pem \
  -n cloudflared

That part matters because a surprising number of “Cloudflare Tunnel on Kubernetes” examples stop before they describe how the runtime actually gets the credentials.

3. Define the Tunnel Config

The useful shape of the cloudflared config map looked like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: v1
kind: ConfigMap
metadata:
  name: cloudflared
  namespace: cloudflared
data:
  config.yaml: |
    tunnel: example-tunnel
    credentials-file: /etc/cloudflared/creds/credentials.json
    metrics: 0.0.0.0:2000
    no-autoupdate: true
    ingress:
      - hostname: tcp.example.com
        service: tcp://tcp-service.default.svc.cluster.local:21
      - service: http_status:404

The original note was tied to a specific service and domain. I have generalized both here.

4. Run the Deployment

The deployment pattern was straightforward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: cloudflared
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cloudflared
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:latest
          args:
            - tunnel
            - --config
            - /etc/cloudflared/config/config.yaml
            - run

That is the point where the pattern becomes operational instead of conceptual.

5. Validate the Tunnel Mapping

Two checks from the original note are worth keeping:

1
2
3
4
5
kubectl exec -it <cloudflared-pod> -n cloudflared -- \
  cloudflared tunnel --config /etc/cloudflared/config/config.yaml ingress validate

kubectl exec -it <cloudflared-pod> -n cloudflared -- \
  cloudflared tunnel --config /etc/cloudflared/config/config.yaml ingress rule tcp://tcp.example.com

Those checks are useful because they tell you whether the config inside the running pod actually matches what you think you deployed.

Closing Thought

The nice thing about this pattern is that it works for more than just one app. Once you have the basic cloudflared deployment and secret flow in place, exposing another TCP service is mostly a matter of changing the hostname and the target service.