Loading

Back to Home

Homelab Rebuild Part 9: Going Public Safely with Cloudflare Tunnel

| October 12, 2026 | 4 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 quietly publishes names you never meant to.
  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

References