Back to Home

Homelab Rebuild Part 9: Going Public Safely with Cloudflare Tunnel

| October 12, 2026 | 5 min to read

Intro

Everything so far was private: LAN or tailnet only. For future apps I wanted a way to publish selected ones on franzramadhan.dev without opening a port on my router, and without accidentally publishing everything else.

TL;DR: A remotely managed Cloudflare Tunnel with two cloudflared replicas forwards *.franzramadhan.dev to a dedicated public-gateway. Exposure is opt-in per route: only HTTPRoutes attached to public-gateway with external-dns.kubernetes.io/cloudflare-proxied: "true" get a proxied CNAME to the tunnel. The dashboard’s wildcard DNS record is deleted on purpose.

The Situation

  • cloudflared 2026.10.0, remotely managed tunnel
  • Cilium Gateway API (lab-gateway on 192.168.101.152 for LAN apps)
  • external-dns 0.23.0 managing the Cloudflare zone (Part 6)
  • The tunnel token stored in OpenBao and synced by ESO (Part 5)

Setup

The tunnel

Following Cloudflare’s Kubernetes guide: create the tunnel in the Zero Trust dashboard, take the token from its install command, and run cloudflared as a Deployment. Two replicas, one per strong Proxmox host:

containers:
  - name: cloudflared
    image: cloudflare/cloudflared:2026.10.0
    args: [tunnel, --no-autoupdate, --metrics, 0.0.0.0:2000, run]
    env:
      - name: TUNNEL_TOKEN
        valueFrom:
          secretKeyRef:
            name: cloudflared-token
            key: token

It runs as non-root with a read-only filesystem and all capabilities dropped. Both replicas registered eight connections to Cloudflare’s edge.

Two gateways: one public, one private

The tunnel’s route in the dashboard sends *.franzramadhan.dev to http://cilium-gateway-public-gateway.gateway.svc.cluster.local:80. Cloudflare terminates TLS at the edge; inside the cluster it’s plain HTTP.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: public-gateway
  annotations:
    external-dns.kubernetes.io/target: <tunnel-id>.cfargotunnel.com
spec:
  gatewayClassName: cilium
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      hostname: "*.franzramadhan.dev"
      allowedRoutes:
        namespaces:
          from: All
Route attached toDNS recordReachable from
lab-gatewayA to 192.168.101.152 (DNS only)LAN
public-gateway + cloudflare-proxied: "true"proxied CNAME to the tunnelinternet

Because the tunnel only forwards to public-gateway, a LAN-only route can’t be reached through it, even with a forged Host header.

Publishing an app

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: my-app
  annotations:
    external-dns.kubernetes.io/cloudflare-proxied: "true"
spec:
  parentRefs:
    - name: public-gateway
      namespace: gateway
  hostnames:
    - my-app.franzramadhan.dev

Issue 1: The Dashboard Published Everything

Symptom

Adding the tunnel route created a proxied wildcard CNAME *.franzramadhan.dev pointing at the tunnel.

Cause

The dashboard creates a DNS record for every public hostname, including wildcards. Every subdomain without its own record would go to the tunnel.

Fix

Delete the wildcard record and let external-dns create one record per opted-in route. If the dashboard ever recreates it, delete it again.

Issue 2: The Record Resolved to a Private IP

Symptom

The test route got an A record to the gateway’s private IP, DNS only, and Cloudflare answered error 1016 (origin DNS error).

Cause

I had annotated with external-dns.alpha.kubernetes.io/..., the prefix from older docs. external-dns 0.2x ignores it silently, so it fell back to the Gateway’s address. It also reads cloudflare-proxied only from the route.

Fix

external-dns.kubernetes.io/target on the Gateway, cloudflare-proxied on the route. The record became a proxied CNAME to <tunnel-id>.cfargotunnel.com.

Dead end: the tunnel “still” didn’t work

After the fix, curl from my laptop still timed out. Forcing Cloudflare’s IP proved the tunnel fine:

curl --resolve tunnel-test.franzramadhan.dev:443:104.21.32.71 https://tunnel-test.franzramadhan.dev/hostname
# echo-86d8bddc4d-rqfwz

Pi-hole (and my Mac) had cached the old private A record. The laptop connected to 192.168.101.154:443, where nothing listens. pihole reloaddns cleared it.

Lessons

  1. Make public exposure an explicit, per-app decision. A separate gateway turns it into a parentRef.
  2. Delete the convenience wildcard. It publishes every subdomain that has no record of its own, including ones you never meant to expose.
  3. When DNS changes, test against the authoritative answer before blaming the service. Caches lie for up to a TTL.

Series

This post is part of Homelab Rebuild:

  1. Part 1: Upgrading Proxmox VE 8 to 9
  2. Part 2: Starting Over When the IaC Is Broken
  3. Part 3: When Tailscale Breaks Your Cilium Gateway
  4. Part 4: Living on the Tailnet
  5. Part 5: Secrets Without Secrets in Git
  6. Part 6: Certificates, DNS and a Private CA
  7. Part 7: Self-Hosted CI and Getting Back to IaC
  8. Part 8: Stateful Workloads on Unequal Hardware
  9. Part 9: Going Public Safely with Cloudflare Tunnel (this post)
  10. Part 10: When the Hypervisor Went Quiet
  11. Part 11: Hardening the Cluster in Five Layers
  12. Part 12: Observability with Coroot, and Losing pve-01
  13. Part 13: The Host That Kept Losing Power
  14. Part 14: Observability, Take Two with OpenTelemetry
  15. Part 15: Requests and Limits for Everything
  16. Part 16: Handing the Cluster to FluxCD
  17. Part 17: Making the Guardrails Earn Their Keep
  18. Part 18: When the API Server Outgrew Its VM
  19. Part 19: Logging In to OpenBao with Cloudflare Access and Google
  20. Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access
  21. Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
  22. Part 22: Turning Four Days of Fixes into AI Runbooks
  23. Part 23: microVM Sandboxes on Proxmox
  24. Part 24: Pi-hole Without a Home Host
  25. Part 25: It Was the Router
  26. Part 26: TLS on the Front, Plaintext Behind It
  27. Part 27: One Bad Power Adapter, Two Different Deaths
  28. Part 28: Observability, Take Three: Making It Worth Looking At
  29. Part 29: One Meter for Every Model, LiteLLM on Workers AI
  30. Part 30: Proxmox Joins the SSO, and a Plan Token Loses Admin
  31. Part 31: First Backups, Postgres, OpenBao and etcd to R2
  32. Part 32: Alerts That Reach Me, Even When the Lab Is Dead
  33. Part 33: The Firewall Held, the Tailnet Did Not
  34. Part 34: From Watching to Blocking, and a Record of Who Did What
  35. Part 35: Wazuh Without the 8 GB, Endpoint Security for One Mac and Three Hosts
  36. Part 36: The Fans Were Loud, the CPUs Were Idle
  37. Part 37: A Power Trip, a Search Domain and a Wildcard
  38. Part 38: An Autonomous AI Pentester in a Sealed Box
  39. Part 39: Wasm Pods on k0s, and the Label That Kept Them Packed

References