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
cloudflaredreplicas forwards*.franzramadhan.devto a dedicatedpublic-gateway. Exposure is opt-in per route: only HTTPRoutes attached topublic-gatewaywithexternal-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-gatewayon192.168.101.152for 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 to | DNS record | Reachable from |
|---|---|---|
lab-gateway | A to 192.168.101.152 (DNS only) | LAN |
public-gateway + cloudflare-proxied: "true" | proxied CNAME to the tunnel | internet |
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
- Make public exposure an explicit, per-app decision. A separate gateway
turns it into a
parentRef. - Delete the convenience wildcard. It publishes every subdomain that has no record of its own, including ones you never meant to expose.
- 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:
- Part 1: Upgrading Proxmox VE 8 to 9
- Part 2: Starting Over When the IaC Is Broken
- Part 3: When Tailscale Breaks Your Cilium Gateway
- Part 4: Living on the Tailnet
- Part 5: Secrets Without Secrets in Git
- Part 6: Certificates, DNS and a Private CA
- Part 7: Self-Hosted CI and Getting Back to IaC
- Part 8: Stateful Workloads on Unequal Hardware
- Part 9: Going Public Safely with Cloudflare Tunnel (this post)
- Part 10: When the Hypervisor Went Quiet
- Part 11: Hardening the Cluster in Five Layers
- Part 12: Observability with Coroot, and Losing pve-01
- Part 13: The Host That Kept Losing Power
- Part 14: Observability, Take Two with OpenTelemetry
- Part 15: Requests and Limits for Everything
- Part 16: Handing the Cluster to FluxCD
- Part 17: Making the Guardrails Earn Their Keep
- Part 18: When the API Server Outgrew Its VM
- Part 19: Logging In to OpenBao with Cloudflare Access and Google
- Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access
- Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
- Part 22: Turning Four Days of Fixes into AI Runbooks
- Part 23: microVM Sandboxes on Proxmox
- Part 24: Pi-hole Without a Home Host
- Part 25: It Was the Router
- Part 26: TLS on the Front, Plaintext Behind It
- Part 27: One Bad Power Adapter, Two Different Deaths
- Part 28: Observability, Take Three: Making It Worth Looking At
- Part 29: One Meter for Every Model, LiteLLM on Workers AI
- Part 30: Proxmox Joins the SSO, and a Plan Token Loses Admin
- Part 31: First Backups, Postgres, OpenBao and etcd to R2
- Part 32: Alerts That Reach Me, Even When the Lab Is Dead
- Part 33: The Firewall Held, the Tailnet Did Not
- Part 34: From Watching to Blocking, and a Record of Who Did What
- Part 35: Wazuh Without the 8 GB, Endpoint Security for One Mac and Three Hosts
- Part 36: The Fans Were Loud, the CPUs Were Idle
- Part 37: A Power Trip, a Search Domain and a Wildcard
- Part 38: An Autonomous AI Pentester in a Sealed Box
- Part 39: Wasm Pods on k0s, and the Label That Kept Them Packed