Going Public Without Opening a Port: Cloudflare Tunnel for Homelabs

Public access to homelab services via Cloudflare Tunnel: zero open router ports, automatic DNS-01 certificates, and the X-Forwarded-Proto gotcha that breaks every app.

Going Public Without Opening a Port: Cloudflare Tunnel for Homelabs

My homelab services were accessible on the LAN via .homelab.local hostnames. But I wanted public access: a portfolio site, monitoring dashboards reachable from my phone, RSS feeds accessible anywhere. The traditional approach is port forwarding: open ports 80 and 443 on the router and hope nobody scans you. I went with Cloudflare Tunnel instead.

How Cloudflare Tunnel Works

A small agent (cloudflared) runs as a pod inside the cluster and establishes an outbound QUIC connection to Cloudflare's edge network. Traffic flows in through Cloudflare, gets filtered and DDoS-protected, then arrives at the cluster through the existing tunnel. Zero inbound ports open on the router. No dynamic DNS. No hairpin NAT issues.

The architecture decision that made this clean: all tunnel traffic routes to Traefik. The cloudflared config points at traefik.traefik.svc.cluster.local as the single destination. Adding a new public service doesn't require touching cloudflared at all: you just add a new Ingress resource and Traefik handles the routing.

The Setup

The deployment lives in infrastructure/cloudflare-tunnel/:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: cloudflare
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cloudflared
  template:
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:2025.2.0
          args:
            - tunnel
            - --config
            - /etc/cloudflared/config.yml
            - run

The ConfigMap routes all *.naviauxlab.com traffic to Traefik:

tunnel: 9e07ff4e-a59d-4f35-9056-1ff40dd99c61
credentials-file: /etc/cloudflared/credentials.json
ingress:
  - hostname: "*.naviauxlab.com"
    service: http://traefik.traefik.svc.cluster.local:80
  - service: http_status:404

DNS: a wildcard CNAME record *.naviauxlab.com points to the tunnel UUID at <uuid>.cfargotunnel.com. Any new subdomain automatically resolves through the tunnel.

DNS-01 Certificates

With Cloudflare managing DNS, I switched cert-manager from HTTP-01 to DNS-01 validation. This means cert-manager creates a TXT record via the Cloudflare API to prove domain ownership (no inbound port 80 needed):

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token

The Cloudflare API token is SOPS-encrypted in Git. Certificates issue and renew automatically.

The X-Forwarded-Proto Problem

The first service I tested through the tunnel was TT-RSS (my RSS reader). It immediately showed a fatal error:

Fatal error E_URL_SCHEME_MISMATCH
SELF_URL_PATH: http://rss.naviauxlab.com
CLIENT_LOCATION: https://rss.naviauxlab.com/

The browser used HTTPS (terminated at Cloudflare), but TT-RSS saw the request as HTTP. Tracing the full request path revealed the issue:

  1. Browserhttps://rss.naviauxlab.com (HTTPS)
  2. Cloudflare Edge → terminates TLS, connects to tunnel
  3. cloudflared → forwards to http://traefik:80 (HTTP)
  4. Traefik → receives on port 80, sets X-Forwarded-Proto: http
  5. TT-RSS → reads the header, constructs http:// URL → mismatch

The cloudflared-to-Traefik connection is HTTP (internal cluster traffic, no TLS needed). But Traefik correctly reports what it received, which is HTTP. Applications that check X-Forwarded-Proto see a scheme mismatch.

The Fix: Traefik Middleware

A Traefik Middleware that overrides the header before it reaches the application:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: https-scheme
  namespace: traefik
spec:
  headers:
    customRequestHeaders:
      X-Forwarded-Proto: "https"
      X-Forwarded-Ssl: "on"

Attached to any Ingress via annotation:

annotations:
  traefik.ingress.kubernetes.io/router.middlewares: \
    traefik-https-scheme@kubernetescrd

This middleware is reusable for any service behind the tunnel that checks X-Forwarded-Proto. WordPress, Ghost, Django, Rails: they all do this.

Dual-Hostname Ingress Pattern

Every service now has two hostnames: the public .naviauxlab.com domain and the LAN .homelab.local domain. Both work through the same Ingress resource:

spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - rss.homelab.local
      secretName: ttrss-tls
  rules:
    - host: rss.naviauxlab.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: ttrss-app
                port:
                  number: 80
    - host: rss.homelab.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: ttrss-app
                port:
                  number: 80

Note that TLS is only specified for .homelab.local. Cloudflare handles TLS termination for .naviauxlab.com. The cert never needs to exist in the cluster for public domains.

NetworkPolicies for the Tunnel

The cloudflare namespace gets the same default-deny treatment as everything else, with explicit allow rules:

  • Egress to Cloudflare: QUIC on port 7844 to Cloudflare's IP ranges
  • Egress to Traefik: HTTP on port 80 to traefik.traefik.svc
  • DNS egress: CiliumNetworkPolicy for CoreDNS access
  • Prometheus scrape: monitoring can reach cloudflared metrics

What I Learned

Route the tunnel to Traefik, not individual services. This means cloudflared's config never changes when you add a service. New public services only need a new Ingress resource. Direct-to-service routing creates a maintenance burden.

Trace the full request path before debugging application config. The TT-RSS scheme mismatch looked like an application problem but was actually a reverse proxy problem. Map the chain (browser → CDN → tunnel → ingress → service → pod) and fix at the layer that transforms the header.

Manual kubectl changes get reverted by Flux. I initially fixed the TT-RSS SELF_URL_PATH with kubectl set env. It worked for about a minute until Flux reverted the deployment to what was in Git. Every change must go through Git. This is GitOps working as designed.

The result: 15 services publicly accessible at *.naviauxlab.com, real Let's Encrypt certificates, and zero open ports on my router.