custom-domain-setup
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
-
Domain on Cloudflare:
- Your domain (
zabaca.com) must use Cloudflare DNS - Cannot delegate just a subdomain - entire domain must be on Cloudflare
- Your domain (
-
Cloudflare Account:
- Free tier is sufficient
- Zero Trust dashboard access
-
cloudflared CLI:
Terminal window # Install on local machine (for setup)curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflaredsudo install -m 755 cloudflared /usr/local/bin/ -
k3s Cluster:
- Running k3s cluster
- kubectl access
Setup Process
Step 1: Create Named Tunnel
# Authenticate with Cloudflarecloudflared tunnel login
# Create a named tunnelcloudflared tunnel create zabaca-k3s
# This creates:# - A tunnel UUID (e.g., abc123-def456-...)# - Credentials file: ~/.cloudflared/<UUID>.json# - Tunnel registered in Cloudflare dashboardOutput:
Tunnel credentials written to ~/.cloudflared/abc123-def456-ghi789-jkl012.jsonCreated tunnel zabaca-k3s with id abc123-def456-ghi789-jkl012Step 2: Configure DNS
Option A: Automatic (Recommended)
# Route subdomain to tunnel (creates CNAME automatically)cloudflared tunnel route dns zabaca-k3s transcribe.zabaca.comOption B: Manual
- Go to Cloudflare Dashboard → DNS
- Add CNAME record:
- Name:
transcribe - Target:
<UUID>.cfargotunnel.com - Proxy status: Proxied (orange cloud)
- Name:
Step 3: Create Configuration File
Create config.yaml for the tunnel:
tunnel: abc123-def456-ghi789-jkl012 # Replace with your UUIDcredentials-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:404Notes:
- 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
# Create namespace (if needed)kubectl create namespace cloudflare-tunnel
# Create secret from tunnel credentialskubectl create secret generic tunnel-credentials \ --from-file=creds.json=$HOME/.cloudflared/abc123-def456-ghi789-jkl012.json \ -n cloudflare-tunnel4B: Create ConfigMap
kubectl create configmap cloudflared-config \ --from-file=config.yaml=./config.yaml \ -n cloudflare-tunnel4C: Deploy cloudflared
cloudflared-deployment.yaml:
apiVersion: apps/v1kind: Deploymentmetadata: name: cloudflared namespace: cloudflare-tunnelspec: 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-credentialsApply the deployment:
kubectl apply -f cloudflared-deployment.yamlStep 5: Verify
# Check pod statuskubectl get pods -n cloudflare-tunnel
# Check logskubectl logs -n cloudflare-tunnel -l app=cloudflared
# Test the URLcurl https://transcribe.zabaca.comMultiple Subdomains Example
To expose multiple k3s services:
Updated config.yaml:
tunnel: abc123-def456-ghi789-jkl012credentials-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:404Update the ConfigMap:
kubectl delete configmap cloudflared-config -n cloudflare-tunnelkubectl create configmap cloudflared-config \ --from-file=config.yaml=./config.yaml \ -n cloudflare-tunnel
# Restart deployment to pick up changeskubectl rollout restart deployment/cloudflared -n cloudflare-tunnelAlternative: 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:404Then manage routing with Ingress resources:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: transcribe-ingress namespace: youtube-transcriptspec: rules: - host: transcribe.zabaca.com http: paths: - path: / pathType: Prefix backend: service: name: youtube-transcript-web port: number: 8000Option 2: Cloudflare Tunnel Ingress Controller
Community project that automatically creates tunnels from Ingress resources:
Installation:
helm repo add strrl.dev https://helm.strrl.devhelm 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-namespaceUsage:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: transcribe annotations: kubernetes.io/ingress.class: cloudflare-tunnelspec: rules: - host: transcribe.zabaca.com http: paths: - path: / pathType: Prefix backend: service: name: youtube-transcript-web port: number: 8000The controller automatically:
- Creates DNS records
- Configures tunnel routing
- Manages SSL certificates
Comparison: Direct vs Ingress Routing
| Approach | Pros | Cons |
|---|---|---|
| Direct Service Routing | Simple, no ingress needed, explicit config | Manual DNS/config updates, harder to scale |
| Route to Traefik | Reuse existing ingress, k8s-native routing | Extra hop, depends on Traefik |
| Tunnel Ingress Controller | Fully automated, k8s-native, easy to scale | Extra 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:
# Verify service existskubectl get svc -n youtube-transcript
# Test service internallykubectl run -it --rm debug --image=curlimages/curl --restart=Never -- \ curl http://youtube-transcript-web.youtube-transcript.svc.cluster.local:8000DNS not resolving
Check:
- CNAME record exists in Cloudflare DNS
- Proxied (orange cloud) is enabled
- Wait 1-2 minutes for DNS propagation
Pod crashes with “credential error”
Fix:
# Verify credentials secretkubectl get secret tunnel-credentials -n cloudflare-tunnel -o yaml
# Recreate if neededkubectl delete secret tunnel-credentials -n cloudflare-tunnelkubectl 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:
- Verify service is running:
kubectl get pods -n youtube-transcript - Check service port:
kubectl get svc youtube-transcript-web -n youtube-transcript - 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
- Access Control: Add Cloudflare Access for authentication
- Secrets Management: Use sealed-secrets or external-secrets for credentials
- Network Policies: Restrict pod-to-pod communication
- Least Privilege: Use read-only mounts where possible
- 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
- Cloudflare Tunnel FAQ
- Create a tunnel (dashboard)
- DNS records for Cloudflare Tunnel
- Kubernetes deployment guide
- Create a locally-managed tunnel
- Exposing Kubernetes Services through Cloudflare Tunnels
- Cloudflare Tunnel Ingress Controller
- Free, Stable, and Custom: Cloudflare Tunnel with Your Own Domain
- Step-By-Step Guide: Setting Up Persistent Cloudflare Tunnels
- Configuring Cloudflare Tunnels in k3s