Purpose

Expose local k3s services to the internet using custom subdomains on your own domain (e.g., transcribe.zabaca.com) via Cloudflare Tunnel, eliminating the need for public IPs, port forwarding, or firewall configuration.

Use Case Example:

  • Local service: transcribe.local (k3s service at ~/Projects/uptownhr/agents/packages/k3s/youtube-transcript-web)
  • Public URL: https://transcribe.zabaca.com

Key Concepts

Named vs Temporary Tunnels

Temporary Tunnels (npx cloudflared tunnel --url http://localhost:8000):

  • Random URLs like https://xyz.trycloudflare.com
  • No persistence - URL changes on restart
  • No authentication required
  • Good for quick testing only

Named Tunnels (Persistent):

  • Custom domain/subdomain support (transcribe.zabaca.com)
  • Persistent UUID - URL never changes
  • Requires Cloudflare account and domain
  • Production-ready and reliable

Architecture

┌─────────────────────┐
│ k3s Service │
│ transcribe.local │
│ (port 8000) │
└──────────┬──────────┘
┌─────────────────────┐
│ cloudflared pod │
│ (in k8s) │
└──────────┬──────────┘
│ Outbound connection
│ (no inbound ports!)
┌─────────────────────┐
│ Cloudflare Edge │
│ Network │
└──────────┬──────────┘
┌─────────────────────┐
│ Public Internet │
│ transcribe. │
│ zabaca.com │
└─────────────────────┘

Prerequisites

  1. Domain on Cloudflare:

    • Your domain (zabaca.com) must use Cloudflare DNS
    • Cannot delegate just a subdomain - entire domain must be on Cloudflare
  2. Cloudflare Account:

    • Free tier is sufficient
    • Zero Trust dashboard access
  3. cloudflared CLI:

    Terminal window
    # Install on local machine (for setup)
    curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
    sudo install -m 755 cloudflared /usr/local/bin/
  4. k3s Cluster:

    • Running k3s cluster
    • kubectl access

Setup Process

Step 1: Create Named Tunnel

Terminal window
# Authenticate with Cloudflare
cloudflared tunnel login
# Create a named tunnel
cloudflared tunnel create zabaca-k3s
# This creates:
# - A tunnel UUID (e.g., abc123-def456-...)
# - Credentials file: ~/.cloudflared/<UUID>.json
# - Tunnel registered in Cloudflare dashboard

Output:

Tunnel credentials written to ~/.cloudflared/abc123-def456-ghi789-jkl012.json
Created tunnel zabaca-k3s with id abc123-def456-ghi789-jkl012

Step 2: Configure DNS

Option A: Automatic (Recommended)

Terminal window
# Route subdomain to tunnel (creates CNAME automatically)
cloudflared tunnel route dns zabaca-k3s transcribe.zabaca.com

Option B: Manual

  1. Go to Cloudflare Dashboard → DNS
  2. Add CNAME record:
    • Name: transcribe
    • Target: <UUID>.cfargotunnel.com
    • Proxy status: Proxied (orange cloud)

Step 3: Create Configuration File

Create config.yaml for the tunnel:

tunnel: abc123-def456-ghi789-jkl012 # Replace with your UUID
credentials-file: /etc/cloudflared/creds.json
ingress:
# Route transcribe.zabaca.com to k8s service
- hostname: transcribe.zabaca.com
service: http://youtube-transcript-web.youtube-transcript.svc.cluster.local:8000
# Add more subdomains as needed
# - hostname: another.zabaca.com
# service: http://another-service:8080
# Catch-all (required)
- service: http_status:404

Notes:

  • Service URL format: http://<service-name>.<namespace>.svc.cluster.local:<port>
  • Multiple subdomains can share one tunnel
  • Order matters - first match wins

Step 4: Deploy to k3s

4A: Create Secret with Credentials

Terminal window
# Create namespace (if needed)
kubectl create namespace cloudflare-tunnel
# Create secret from tunnel credentials
kubectl create secret generic tunnel-credentials \
--from-file=creds.json=$HOME/.cloudflared/abc123-def456-ghi789-jkl012.json \
-n cloudflare-tunnel

4B: Create ConfigMap

Terminal window
kubectl create configmap cloudflared-config \
--from-file=config.yaml=./config.yaml \
-n cloudflare-tunnel

4C: Deploy cloudflared

cloudflared-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflare-tunnel
spec:
replicas: 2 # Multiple replicas for HA
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --config
- /etc/cloudflared/config.yaml
- run
volumeMounts:
- name: config
mountPath: /etc/cloudflared
readOnly: true
- name: creds
mountPath: /etc/cloudflared/creds.json
subPath: creds.json
readOnly: true
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
memory: 64Mi
volumes:
- name: config
configMap:
name: cloudflared-config
- name: creds
secret:
secretName: tunnel-credentials

Apply the deployment:

Terminal window
kubectl apply -f cloudflared-deployment.yaml

Step 5: Verify

Terminal window
# Check pod status
kubectl get pods -n cloudflare-tunnel
# Check logs
kubectl logs -n cloudflare-tunnel -l app=cloudflared
# Test the URL
curl https://transcribe.zabaca.com

Multiple Subdomains Example

To expose multiple k3s services:

Updated config.yaml:

tunnel: abc123-def456-ghi789-jkl012
credentials-file: /etc/cloudflared/creds.json
ingress:
- hostname: transcribe.zabaca.com
service: http://youtube-transcript-web.youtube-transcript.svc.cluster.local:8000
- hostname: api.zabaca.com
service: http://api-service.default.svc.cluster.local:3000
- hostname: blog.zabaca.com
service: http://wordpress.blog.svc.cluster.local:80
- service: http_status:404

Update the ConfigMap:

Terminal window
kubectl delete configmap cloudflared-config -n cloudflare-tunnel
kubectl create configmap cloudflared-config \
--from-file=config.yaml=./config.yaml \
-n cloudflare-tunnel
# Restart deployment to pick up changes
kubectl rollout restart deployment/cloudflared -n cloudflare-tunnel

Alternative: Using Ingress Controller

Instead of routing directly to services, route through an ingress controller:

Option 1: Route to Traefik (k3s default)

ingress:
- hostname: "*.zabaca.com"
service: http://traefik.kube-system.svc.cluster.local:80
- service: http_status:404

Then manage routing with Ingress resources:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: transcribe-ingress
namespace: youtube-transcript
spec:
rules:
- host: transcribe.zabaca.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: youtube-transcript-web
port:
number: 8000

Option 2: Cloudflare Tunnel Ingress Controller

Community project that automatically creates tunnels from Ingress resources:

Installation:

Terminal window
helm repo add strrl.dev https://helm.strrl.dev
helm upgrade --install cloudflare-tunnel-ingress-controller strrl.dev/cloudflare-tunnel-ingress-controller \
--set cloudflare.apiToken="YOUR_API_TOKEN" \
--set cloudflare.accountId="YOUR_ACCOUNT_ID" \
--set cloudflare.tunnelName="k3s-ingress" \
-n cloudflare-tunnel-ingress-controller --create-namespace

Usage:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: transcribe
annotations:
kubernetes.io/ingress.class: cloudflare-tunnel
spec:
rules:
- host: transcribe.zabaca.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: youtube-transcript-web
port:
number: 8000

The controller automatically:

  • Creates DNS records
  • Configures tunnel routing
  • Manages SSL certificates

Comparison: Direct vs Ingress Routing

ApproachProsCons
Direct Service RoutingSimple, no ingress needed, explicit configManual DNS/config updates, harder to scale
Route to TraefikReuse existing ingress, k8s-native routingExtra hop, depends on Traefik
Tunnel Ingress ControllerFully automated, k8s-native, easy to scaleExtra dependency, complexity

Recommendation:

  • 1-5 services: Direct routing (simplest)
  • 5+ services: Route to Traefik or use Ingress Controller

Troubleshooting

Tunnel connects but returns 404

Cause: Service URL is incorrect or service not accessible

Fix:

Terminal window
# Verify service exists
kubectl get svc -n youtube-transcript
# Test service internally
kubectl run -it --rm debug --image=curlimages/curl --restart=Never -- \
curl http://youtube-transcript-web.youtube-transcript.svc.cluster.local:8000

DNS not resolving

Check:

  1. CNAME record exists in Cloudflare DNS
  2. Proxied (orange cloud) is enabled
  3. Wait 1-2 minutes for DNS propagation

Pod crashes with “credential error”

Fix:

Terminal window
# Verify credentials secret
kubectl get secret tunnel-credentials -n cloudflare-tunnel -o yaml
# Recreate if needed
kubectl delete secret tunnel-credentials -n cloudflare-tunnel
kubectl create secret generic tunnel-credentials \
--from-file=creds.json=$HOME/.cloudflared/<UUID>.json \
-n cloudflare-tunnel

“connection refused” in logs

Cause: Service not running or wrong port

Fix:

  1. Verify service is running: kubectl get pods -n youtube-transcript
  2. Check service port: kubectl get svc youtube-transcript-web -n youtube-transcript
  3. Update config.yaml with correct port

Benefits

No Public IP Required: Works behind NAT/firewall ✅ No Port Forwarding: No router configuration needed ✅ Auto SSL/TLS: Cloudflare provides certificates ✅ DDoS Protection: Cloudflare edge network protection ✅ Zero Inbound Ports: Only outbound connection from cluster ✅ High Availability: Multiple replicas supported ✅ Free Tier Available: No cost for basic usage

Security Considerations

  1. Access Control: Add Cloudflare Access for authentication
  2. Secrets Management: Use sealed-secrets or external-secrets for credentials
  3. Network Policies: Restrict pod-to-pod communication
  4. Least Privilege: Use read-only mounts where possible
  5. Audit Logging: Enable Cloudflare audit logs

Cost

  • Cloudflare Tunnel: Free (unlimited tunnels)
  • Cloudflare DNS: Free
  • Cloudflare Zero Trust: Free tier available (50 users)
  • Bandwidth: Free for standard usage

Sources

  1. Cloudflare Tunnel FAQ
  2. Create a tunnel (dashboard)
  3. DNS records for Cloudflare Tunnel
  4. Kubernetes deployment guide
  5. Create a locally-managed tunnel
  6. Exposing Kubernetes Services through Cloudflare Tunnels
  7. Cloudflare Tunnel Ingress Controller
  8. Free, Stable, and Custom: Cloudflare Tunnel with Your Own Domain
  9. Step-By-Step Guide: Setting Up Persistent Cloudflare Tunnels
  10. Configuring Cloudflare Tunnels in k3s