Public names are proxied CNAMEs to the Cloudflare Tunnel since PR #156 and work from the LAN, so they should resolve publicly there too. Replace the address=/${BASE_DOMAIN}/ wildcard with explicit entries: the LAN-only names (derived from every controller:none HTTPRoute), gitea/nextcloud (off Cloudflare's 100 MB / 100 s limits), keycloak/auth, and the L4 VIPs. Add L0 check pihole-lan-names, mutation-tested. Correct docs that still described public names as CNAME to wan. and the cloudflared entrypoint comment. Co-Authored-By: Claude Opus 5.5 <[email protected]>
platform/externaldns
ExternalDNS — automatic public DNS records for public services (Cloudflare).
- Spec: docs/09 — DNS & Ingress (ExternalDNS is named in docs/01; no dedicated ADR)
- Serves: R-DNS-01/02/03
- Owner: WS-NET (token from WS-SEC)
What this stub is
Flux HelmRelease watching Gateway/HTTPRoute + Service resources, publishing
<service>.home.keithjackson.dev records in Cloudflare from declared intent (no manual record
editing, R-DNS-02).
Split-horizon note (docs/09)
A wildcard *.keithjackson.dev matches only one label deep, so <svc>.home.keithjackson.dev needs
its own record. internal services need NO public record — PiHole resolves them to the internal
Gateway VIP (split-horizon). ExternalDNS manages public services' records only.
Public target: the Cloudflare Tunnel, proxied (2026-11-08)
Gateway-published records are proxied CNAMEs to the Cloudflare Tunnel
(platform/cloudflared), not the WAN IP and not the Gateway VIP:
gitea.home.keithjackson.dev CNAME (proxied) fc81938b-5855-47f1-8a27-6286f56dda93.cfargotunnel.com <- external-dns
unifi.home.keithjackson.dev A (DNS-only) 192.168.2.21 <- external-dns, `service` source (L4)
crafty-mc.home... A (DNS-only) 192.168.2.22 <- external-dns, `service` source (L4)
wan.home.keithjackson.dev A (DNS-only) <current WAN IP> <- cloudflare-ddns (ddns.yaml): WireGuard udp/51820 ONLY
- The
targetannotation is on the shared Gateway (platform/gateway/gateway.yaml); external-dns reads it from the Gateway only, so it applies to every route that publishes at all. Its UUID must equaltunnel:inplatform/cloudflared/config.yaml(L0 checkinternal-services-publish-no-public-dns(9a)). --cloudflare-proxied(HelmReleaseextraArgs) orange-clouds every record by default — the only practical lever, because v0.23.0 reads the per-objectcloudflare-proxiedannotation from the route/Service, never the Gateway. Everyservice-source publisher must carryexternal-dns.alpha.kubernetes.io/cloudflare-proxied: "false"(L4 ports cannot ride the HTTP proxy); (9b) fails the gate otherwise.internalroutes (controller: none) still publish nothing. Plex's web UI and Headlamp joined that set on 2026-11-08 (LAN/VPN-only); the tunnel also 404s every such name (9c/9d).- Public answers are Cloudflare anycast A and AAAA — Cloudflare's edge, not an origin address.
- Pods resolve the whole zone to the Gateway VIP via CoreDNS (split-horizon) and never use the
public path. LAN clients use PiHole, which since 2026-11-09 answers an EXPLICIT list
(
catalog/pihole.yaml): the LAN-only names, plus gitea/nextcloud/keycloak/auth (kept off Cloudflare's 100 MB / 100 s limits and on one OIDC path), go to the Gateway VIP and the L4 names to their own VIPs; every other public name (apex, grafana, crafty, seerr) resolves to Cloudflare from the LAN exactly as from anywhere else, so no split-DNS is needed for them. Guard:conformance/tests/l0-static/pihole-lan-names. - Nodes resolve through public DNS (
node_baseline_dns_servers), so from a node the apiserver's OIDC issuerkeycloak.home.keithjackson.devnow resolves to Cloudflare and its discovery/JWKS fetches go through the tunnel (publicly trusted edge cert; nooidc-ca-fileis configured). The router NAT hairpin is no longer on that path.
Check: dig +short @1.1.1.1 gitea.home.keithjackson.dev → Cloudflare anycast addresses (no CNAME is
visible for a proxied record); kubectl -n external-dns logs deploy/external-dns.
History
2026-10-03 → 2026-11-08: records were DNS-only CNAMEs to wan.home.keithjackson.dev, with the router
forwarding WAN tcp/80+443 to the Gateway VIP. Before 2026-10-03 they answered the Gateway VIP itself.
Secrets
Cloudflare API token referenced by name (cloudflare-api-token, shared with cert-manager),
SOPS-encrypted (WS-SEC, OQ-4).
TODO (later phases)
- Phase 2 (WS-NET): confirm
gateway-httproutesource; prove an auto record for a test route (R-DNS-02, Phase-2 exit gate). - Phase 6 (WS-APPS): PiHole provides the internal split-horizon answers as it onboards.