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

IssueSymptomFix
Shared TXT owner between instancesRecords appear, then vanish, then reappearUnique txtOwnerId + txtPrefix per external-dns instance
external-dns default sourcesPrivate hostnames leak to Cloudflare via Service annotationsPublic instance watches gateway-httproute only
Public-only HTTPRouteWorks from outside, NXDOMAIN on the LANInternal BIND is authoritative; every public service also needs a route on the private gateway
HTTP-01 for internal namescert-manager challenges fail foreverDNS-01 issuer; the challenge doesn’t care what resolves where
Cloudflare proxied + non-HTTP servicesProxied records only proxy HTTP(S)Keep non-web services on the private gateway or unproxied
RFC2136 over IPv6Updates silently droppedTSIG configured and the BIND host address correct; test with nsupdate first

Outstanding Work

  1. Public gateway hardening: rate limiting and a WAF profile in front of Immich uploads
  2. Second BIND server: the internal zone has a single point of truth right now
  3. Metrics: both instances expose ServiceMonitors; a “records managed” dashboard would catch ownership fights early

References