Exposing Kubernetes Services With Zero Open Inbound Ports

Apr 2, 2026

The usual ways to expose Kubernetes services externally — LoadBalancer, NodePort, Ingress — all require something inbound: a cloud load balancer, an open port on the node, or an IP reachable from outside. On a home network or a machine behind NAT, none of those work without touching the router.

Cloudflare Tunnel (cloudflared) works the other way around.


How It Works

cloudflared runs inside the cluster as a Deployment. It makes an outbound HTTPS connection to Cloudflare’s edge. Cloudflare holds the connection open. When a request arrives for your domain, Cloudflare forwards it through that tunnel to cloudflared, which proxies it to the target service inside the cluster.

No inbound ports. No firewall rules. No public IP. The machine doesn’t need to be reachable from outside at all.

User → Cloudflare edge (HTTPS)
         → tunnel (outbound connection, held open by cloudflared pod)
           → cloudflared pod (inside cluster)
             → target service (ClusterIP, no external exposure)

Setup

You need a domain managed by Cloudflare and an API token with Tunnel and DNS edit permissions.

1. Create the tunnel via API

curl -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "my-cluster",
    "tunnel_secret": "'$(openssl rand -base64 32)'",
    "config_src": "local"
  }'

This returns a tunnel ID and the credentials JSON. Store the credentials JSON in a K8s Secret (or Vault ExternalSecret).

2. Create DNS records

For each service you want to expose, create a CNAME pointing to <tunnel-id>.cfargotunnel.com:

curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "type": "CNAME",
    "name": "grafana",
    "content": "'${TUNNEL_ID}'.cfargotunnel.com",
    "proxied": true
  }'

3. Deploy cloudflared

The ConfigMap maps hostnames to internal cluster services:

apiVersion: v1
kind: ConfigMap
metadata:
  name: cloudflared-config
  namespace: cloudflared
data:
  config.yaml: |
    tunnel: <tunnel-id>
    credentials-file: /etc/cloudflared/creds.json
    ingress:
      - hostname: grafana.yourdomain.com
        service: http://grafana.monitoring.svc.cluster.local:80
      - hostname: argocd.yourdomain.com
        service: http://argocd-server.argocd.svc.cluster.local:80
      - service: http_status:404

The Deployment mounts the credentials Secret and this ConfigMap:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: cloudflared
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:latest
          args:
            - tunnel
            - --no-autoupdate
            - --config
            - /etc/cloudflared/config.yaml
            - run
          volumeMounts:
            - name: credentials
              mountPath: /etc/cloudflared/creds.json
              subPath: creds.json
              readOnly: true
            - name: config
              mountPath: /etc/cloudflared/config.yaml
              subPath: config.yaml
              readOnly: true
      volumes:
        - name: credentials
          secret:
            secretName: cloudflared-credentials
        - name: config
          configMap:
            name: cloudflared-config

Two replicas means Cloudflare has two tunnel connections. If one pod dies, traffic keeps flowing through the other.

What Cloudflare Handles

With proxied: true on the DNS records, Cloudflare terminates TLS at its edge. Your services behind the tunnel serve plain HTTP. No cert-manager, no Let’s Encrypt, no certificate rotation — Cloudflare handles it.

DDoS protection and WAF rules apply automatically. Cloudflare Access can sit in front of any tunnel hostname to require authentication before traffic reaches the cluster.

The Credentials Problem

The tunnel credentials are a JSON blob containing the tunnel ID, account tag, and tunnel secret. Passing a JSON blob through shell variable interpolation is error-prone — special characters in the JSON break argument parsing.

The clean solution: write the JSON to a temp file locally, copy it into the Vault pod with kubectl cp, then load it into Vault using vault kv put key=@/path/to/file. The @file syntax tells Vault to read the value from a file rather than interpreting it as a string argument.

Takeaway

cloudflared turns the connectivity model inside out. Instead of opening ports for inbound traffic, the cluster reaches out to Cloudflare and holds a connection. DNS + CNAME records route traffic into that connection. The cluster machine needs outbound HTTPS (port 443) and nothing else. Every home network allows that by default.