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 quietly publishes names you never meant to.
- 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