Split-Horizon DNS with Gateway API: Two Gateways, Two external-dns, One Wildcard
The Problem
Everything in my cluster serves HTTPS under *.sirmysterion.com, but the audiences differ:
ArgoCD and Grafana are for me, on my network (or VPN); the blog and Immich are for the internet.
I want one mechanism where “make this public” is a small change on the app,
not a separate ingress stack, not manual DNS records, and definitely not exposing the admin tools because a wildcard record points at everything.
The answer turned out to be pleasantly composable: two Cilium Gateways, two external-dns deployments, and the only thing an app ever declares is which Gateways its HTTPRoutes attach to.
Architecture
graph LR
HR1[HTTPRoute: argocd] --> GW1[Gateway: cilium-gateway
private LB IP]
HR2[HTTPRoute: blog] --> GW2[Gateway: cilium-gateway-public
public LB IP]
GW1 -.watched by.-> ED1[external-dns #1
RFC2136]
GW2 -.watched by.-> ED2[external-dns #2
Cloudflare]
ED1 --> BIND[BIND9
internal AAAA]
ED2 --> CF[Cloudflare
proxied records]
Both Gateways are Cilium Gateway API gateways, HTTPS-only on 443, same wildcard hostname, each getting its own IPv6 LoadBalancer address from the Cilium IP pool (advertised via BGP, see the multihomed series):
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: cilium-gateway # and a twin: cilium-gateway-public
namespace: cert-manager
annotations:
cert-manager.io/issuer: cloudflare-issuer
spec:
gatewayClassName: cilium
listeners:
- protocol: HTTPS
port: 443
name: https-gateway
hostname: "*.sirmysterion.com"
tls:
certificateRefs:
- kind: Secret
group: ""
name: cloudflare-cert
allowedRoutes:
namespaces:
from: All
- protocol: HTTPS
port: 443
name: https-gateway-apex
hostname: "sirmysterion.com"
tls:
certificateRefs:
- kind: Secret
group: ""
name: cloudflare-cert-apex
allowedRoutes:
namespaces:
from: All
The second listener exists because a wildcard doesn’t match the bare apex, so sirmysterion.com gets its own certificate.
The Gateways live in the cert-manager namespace, next to the certificates they reference, so no ReferenceGrant is needed.
Same certs on both, issued by cert-manager via Cloudflare DNS-01. DNS-01 because the private gateway’s hostnames don’t resolve publicly, so HTTP-01 was never an option.
Two external-dns Deployments
The trick that makes this work is external-dns’s --gateway-name flag: each instance only sees HTTPRoutes attached to its gateway.
Instance 1: private, RFC2136 to BIND9
sources:
- service
- gateway-httproute
provider:
name: rfc2136
policy: sync
registry: txt
txtOwnerId: k8s
txtPrefix: external-dns-
domainFilters:
- sirmysterion.com
extraArgs:
- --rfc2136-host=2001:db8:abcd:5300::53
- --rfc2136-zone=sirmysterion.com
- --rfc2136-tsig-secret-alg=hmac-sha256
- --rfc2136-tsig-axfr
- --gateway-name=cilium-gateway
Dynamic updates to BIND9 are authenticated with a TSIG key, and the records land as AAAA pointing at the private gateway’s address.
The private instance also watches service, so a LoadBalancer Service with an external-dns.alpha.kubernetes.io/hostname annotation gets an internal record too.
That covers the things that aren’t HTTP and never touch a Gateway.
Instance 2: public, Cloudflare
sources:
- gateway-httproute
provider:
name: cloudflare
policy: sync
registry: txt
txtOwnerId: talos-metal-cf
txtPrefix: external-dns-cf-
extraArgs:
- --gateway-name=cilium-gateway-public
- --cloudflare-proxied
--cloudflare-proxied gives every public record the orange cloud.
A nice side effect for an IPv6-only cluster: Cloudflare terminates client connections and speaks IPv6 to my origin, so IPv4-only visitors can reach services that don’t have a single v4 address anywhere in the path.
Note the different txtOwnerId and txtPrefix on each instance.
external-dns tracks record ownership with TXT registry entries; two instances with the same owner ID in the same zone will fight over, and delete, each other’s records.
Ask me how I know.
What an App Sees
A private service is one HTTPRoute on cilium-gateway:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hugo-blog
namespace: hugo-blog
spec:
parentRefs:
- name: cilium-gateway
namespace: cert-manager
sectionName: https-gateway
hostnames:
- "www.sirmysterion.com"
rules:
- backendRefs:
- name: hugo-blog
port: 80
DNS, TLS, and the load balancer all follow automatically, and the name only resolves on my network.
Making it public is not just swapping the parentRefs to the public gateway.
My internal BIND9 is authoritative for the whole sirmysterion.com zone, so clients on my network never ask Cloudflare.
A route that only attaches to cilium-gateway-public gets a Cloudflare record and nothing internal,
so the service works from my phone on LTE and returns NXDOMAIN from my desk.
A public service is two routes: the private one above, plus a twin on the public gateway:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hugo-blog-public
namespace: hugo-blog
spec:
parentRefs:
- name: cilium-gateway-public # ← the public half
namespace: cert-manager
sectionName: https-gateway
hostnames:
- "www.sirmysterion.com"
rules:
- backendRefs:
- name: hugo-blog
port: 80
Inside the house, www resolves to the private gateway; everywhere else, it resolves to Cloudflare.
Same backend, two front doors. Still one file to add per app, just not one field.
Footguns
| Issue | Symptom | Fix |
|---|---|---|
| Shared TXT owner between instances | Records appear, then vanish, then reappear | Unique txtOwnerId + txtPrefix per external-dns instance |
| external-dns default sources | Private hostnames leak to Cloudflare via Service annotations | Public instance watches gateway-httproute only |
| Public-only HTTPRoute | Works from outside, NXDOMAIN on the LAN | Internal BIND is authoritative; every public service also needs a route on the private gateway |
| HTTP-01 for internal names | cert-manager challenges fail forever | DNS-01 issuer; the challenge doesn’t care what resolves where |
| Cloudflare proxied + non-HTTP services | Proxied records only proxy HTTP(S) | Keep non-web services on the private gateway or unproxied |
| RFC2136 over IPv6 | Updates silently dropped | TSIG configured and the BIND host address correct; test with nsupdate first |
Outstanding Work
- Public gateway hardening: rate limiting and a WAF profile in front of Immich uploads
- Second BIND server: the internal zone has a single point of truth right now
- Metrics: both instances expose ServiceMonitors; a “records managed” dashboard would catch ownership fights early
References
homelab kubernetes cilium dns ipv6 gateway-api
875 Words
2026-09-28 00:00 (Last updated: 2026-10-17 04:41)
8e9f491 @ 2026-10-17