External DNS

external-dns (Helm chart 1.23.0) runs as two instances in networking:

InstanceProviderZonesSources
external-dnsCloudflarewibrow.dev, propagit.dev, cloudsnacks.devcrd, gateway-httproute
external-dns-unifiUniFi gateway (webhook)wibrow.devgateway-httproute, gateway-udproute, service

Both use policy: sync, txtPrefix: k8s. and txtOwnerId: default.

Cloudflare Instance

kubernetes/apps/pitower/networking/external-dns/values.yaml (excerpt)
provider: cloudflare
env:
  - name: CF_API_TOKEN
    valueFrom:
      secretKeyRef:
        name: external-dns-secret
        key: api-token
extraArgs:
  - --ingress-class=external
  - --crd-source-apiversion=externaldns.k8s.io/v1alpha1
  - --crd-source-kind=DNSEndpoint
  - --gateway-label-filter=external-dns.alpha.kubernetes.io/enabled=true
  - --annotation-prefix=external-dns.alpha.kubernetes.io/
policy: sync
sources:
  - crd
  - gateway-httproute
domainFilters: ["wibrow.dev", "propagit.dev", "cloudsnacks.dev"]
SettingPurpose
--gateway-label-filterOnly HTTPRoutes on Gateways labelled external-dns.alpha.kubernetes.io/enabled: "true" are published (both envoy-external and envoy-internal carry it)
--annotation-prefixexternal-dns v0.22.0 dropped the alpha prefix with no fallback; without this flag the controller, target and cloudflare-proxied annotations are ignored
crd sourceDNSEndpoint resources for records not tied to a route
policy: syncDeletes records whose Kubernetes source is gone

There is no --cloudflare-proxied flag, so records are unproxied unless a resource sets cloudflare-proxied: "true". The Cloudflare token comes from Infisical (infisical-networking-external-dns store). The pod is pinned to control-plane nodes.

DNSEndpoints

DNSEndpointRecords
networking/towonel-agentexternal.wibrow.dev, *.wibrow.dev, propagit.dev, *.propagit.dev: CNAME tunnel.wibrow.dev, unproxied
networking/envoy-internalinternal.wibrow.dev: A 10.20.10.238, unproxied
networking/apexwibrow.dev: AAAA 100::, proxied (Cloudflare Worker route)
networking/statusstatus.wibrow.dev: AAAA 100::, proxied (Cloudflare Worker)
pantry-system/pantry*.apps.cloudsnacks.dev, api.pantry.cloudsnacks.dev: CNAME tunnel.wibrow.dev, unproxied

UniFi Instance

external-dns-unifi writes the same hostnames into the UniFi gateway's local DNS, so LAN clients resolve them to the gateway LoadBalancer IPs directly:

kubernetes/apps/pitower/networking/external-dns-unifi/values.yaml (excerpt)
domainFilters:
  - wibrow.dev
provider:
  name: webhook
  webhook:
    image:
      repository: ghcr.io/home-operations/external-dns-unifi-webhook
    env:
      - name: UNIFI_HOST
        value: https://192.168.0.1
      - name: UNIFI_API_KEY
        valueFrom:
          secretKeyRef:
            name: external-dns-unifi-secret
            key: api-key
extraArgs:
  - --annotation-prefix=external-dns.alpha.kubernetes.io/
sources:
  - gateway-httproute
  - gateway-udproute
  - service
triggerLoopOnEvent: true

It has no gateway label filter. The service source publishes the Gateway Services' external-dns.alpha.kubernetes.io/hostname (external.wibrow.dev, internal.wibrow.dev) as A records to their LoadBalancer IPs, which is what the route CNAMEs resolve to on the LAN. The API key comes from Infisical at /networking/external-dns-unifi/api-key.

Annotation Patterns

On Gateways

Annotation / labelPlacementPurpose
external-dns.alpha.kubernetes.io/enabled.metadata.labelsOpts the Gateway's routes in (label, not annotation)
external-dns.alpha.kubernetes.io/target.metadata.annotationsCNAME target for every attached route
external-dns.alpha.kubernetes.io/cloudflare-proxied.metadata.annotationsDefault proxy status for attached routes
external-dns.alpha.kubernetes.io/hostname.spec.infrastructure.annotationsHostname for the generated LoadBalancer Service
lbipam.cilium.io/ips.spec.infrastructure.annotationsPins the Service IP (Cilium)

On HTTPRoutes

AnnotationPurpose
external-dns.alpha.kubernetes.io/controller: noneSkip this route (redirect and fallback routes)
external-dns.alpha.kubernetes.io/cloudflare-proxiedOverride proxy status for this route

Record Creation Flow

sequenceDiagram
    participant Dev as Developer
    participant K8s as Kubernetes API
    participant EDNS as external-dns
    participant CF as Cloudflare API

    Dev->>K8s: HTTPRoute myapp.wibrow.dev, parentRef envoy-external
    K8s->>EDNS: Watch event
    EDNS->>EDNS: Parent Gateway labelled enabled=true?
    EDNS->>EDNS: Target from Gateway annotation (external.wibrow.dev)
    EDNS->>CF: CNAME myapp.wibrow.dev -> external.wibrow.dev (unproxied)
    EDNS->>CF: TXT k8s.myapp.wibrow.dev (ownership)

Gotchas

Troubleshooting

bash
kubectl logs -n networking deploy/external-dns --tail=100
kubectl logs -n networking deploy/external-dns-unifi -c external-dns --tail=100

kubectl get dnsendpoints -A
kubectl get gateway -n networking envoy-external -o jsonpath='{.metadata.labels}'

# What Cloudflare actually has (bypasses router DNS interception)
curl -s "https://1.1.1.1/dns-query?name=myapp.wibrow.dev&type=CNAME" \
  -H "Accept: application/dns-json" | jq '.Answer'

# Force a sync
kubectl rollout restart deploy/external-dns -n networking