Postgres-backed registry for external-dns
  • Go 78.7%
  • Nix 10.2%
  • Just 6.7%
  • PLpgSQL 2.7%
  • Go Template 1.7%
Find a file
Simon Shine 9d14926701
All checks were successful
CI / check (push) Successful in 19m9s
fix(ci): run the integration tests as an unprivileged user
initdb refuses to run as root, and jobs on the nixos runner are root because nix needs
to write a store that lives inside the container. That is why this job has never passed.

ci:
- The integration step goes through as-runner, which the runner image now provides. It
  drops to uid 1000 for that step alone, leaving every other step as root.
2026-08-28 08:47:13 +02:00
.forgejo/workflows fix(ci): run the integration tests as an unprivileged user 2026-08-28 08:47:13 +02:00
chart/external-dns-pgregistry chore(chart): 0.2.4 2026-08-28 08:39:33 +02:00
cmd/external-dns-pgregistry feat(migrate): create the schema from an initContainer 2026-08-28 04:33:48 +02:00
docs refactor: spell out external-dns-pgregistry, and align the chart with upstream's 2026-08-28 01:35:25 +02:00
internal fix(registry): treat an empty zone table as a resting state 2026-08-28 08:38:20 +02:00
nix fix(nix): build without cgo 2026-08-28 07:54:22 +02:00
testdata docs: this binary does run DDL now, and say where the chart lives 2026-08-28 07:17:22 +02:00
.envrc feat: postgres source and registry for external-dns 2026-08-27 22:46:18 +02:00
.gitignore feat: ship a helm chart and publish both to the registry 2026-08-27 23:41:33 +02:00
flake.lock feat: postgres source and registry for external-dns 2026-08-27 22:46:18 +02:00
flake.nix feat: postgres source and registry for external-dns 2026-08-27 22:46:18 +02:00
go.mod feat: postgres source and registry for external-dns 2026-08-27 22:46:18 +02:00
go.sum feat: postgres source and registry for external-dns 2026-08-27 22:46:18 +02:00
justfile refactor(ci): take every tool from the devshell 2026-08-28 07:31:30 +02:00
LICENSE-APACHE feat: postgres source and registry for external-dns 2026-08-27 22:46:18 +02:00
LICENSE-MIT feat: postgres source and registry for external-dns 2026-08-27 22:46:18 +02:00
README.md docs: this binary does run DDL now, and say where the chart lives 2026-08-28 07:17:22 +02:00

external-dns-pgregistry

A Postgres source and registry for external-dns, shipped as a drop-in binary.

Desired DNS state is read from a database instead of from Kubernetes objects, and record ownership is recorded in a table instead of in TXT records written into the customer's zone. Everything else — the reconcile loop, the planner, every provider, the whole flag set — is upstream external-dns, imported as a library.

  your system  ──writes──►  Postgres  ◄──reads──  external-dns-pgregistry  ──►  DNS provider
                               ▲                        │
                               └────writes ownership────┘

Why

external-dns' default registry keeps ownership in the zone it manages: for every record, a companion TXT record saying "external-dns owns this". That works, and it has three costs that grow with the number of zones.

  • Write traffic. Every record costs a second write against the provider's rate limit.
  • The apex problem. For a record at a zone apex, the default naming puts the ownership TXT outside the zone (externaldns-a-example.com is a sibling of example.com), which any zone-scoped provider API rejects. See external-dns#5010.
  • Nothing else can read it. A system that wants to know what external-dns manages has to enumerate zones over the provider API and parse heritage strings back out.

Ownership in Postgres has none of those. The semantics are unchanged: external-dns still refuses to delete or modify a record it has no claim on.

The Kubernetes source has a separate problem at scale — one custom resource per domain, listed in full on every reconcile interval — which a SELECT does not.

Upstream already accepts that the registry is pluggable: --registry=dynamodb stores ownership in a DynamoDB table. This is that idea with a database you probably already run.

Installing

$ helm install edns oci://git.shine.town/infra/charts/external-dns-pgregistry \
    --set txtOwnerId=my-cluster \
    --set postgres.existingSecret=my-dsn \
    --set provider.webhook.image.repository=example/my-provider

An initContainer applies the schema before the pod starts. migrations.schema defaults to ownership, which creates dns_ownership and leaves the rest of the contract to the host. Set it to reference to also create the example host tables and views, for a deployment that has no host system yet:

    --set migrations.schema=reference

The chart is written from scratch rather than derived from the upstream external-dns chart, but value names follow it wherever they mean the same thing — policy, interval, txtOwnerId, domainFilters, provider, serviceMonitor — so values carry over. postgres, shards and shardIdFrom are the additions.

It renders two objects: a StatefulSet and a headless Service. No ServiceAccount, no Role, no ClusterRole, and automountServiceAccountToken: false. That absence is why it warrants its own chart: the upstream chart's entire RBAC exists to read Services and Ingresses, and the Postgres source reads neither.

A StatefulSet rather than upstream's Deployment because each instance owns a distinct slice of the zone table: two pods reconciling the same zone would race each other's writes. The pod ordinal is the shard, so shards: 3 is three shards and kubectl scale adds one. Scaling down strands every zone whose shard_id no longer has a pod, silently, because nothing is left looking at them.

shardIdFrom picks how a pod learns its ordinal:

podIndexLabel (default) read apps.kubernetes.io/pod-index through the downward API. Kubernetes sets it on StatefulSet pods; needs 1.28 with the PodIndexLabel gate, or 1.32 where it is unconditional
hostname parse the ordinal off the pod hostname, via --shard-id-from-hostname. Works on any version

Running it directly

$ external-dns-pgregistry \
    --source=postgres \
    --registry=postgres \
    --postgres-dsn="postgres://user:pass@host/db" \
    --shard-id=0 \
    --txt-owner-id=my-cluster \
    --provider=webhook \
    --policy=sync

Every other upstream flag works unchanged.

Flag
--postgres-dsn connection string; required in Postgres mode. Also EXTERNAL_DNS_POSTGRES_DSN, which is how the chart passes it, because an argument is visible in kubectl describe pod
--shard-id which slice of the zone table this instance owns; default 0. Also EXTERNAL_DNS_SHARD_ID
--shard-id-from-hostname take the shard from a StatefulSet pod's ordinal. The image contains a binary and a CA bundle and no shell, so a chart cannot do this with ${HOSTNAME##*-}
--check-config validate and exit, without connecting to anything
--txt-owner-id the owner claims are recorded under. Named for the TXT registry, though no TXT record is written

--source and --registry are closed enums upstream, so postgres is rewritten to the neutral upstream values before their parser sees it. This is logged at startup.

No cluster access. In Postgres mode the Kubernetes sources are never constructed: no kubeconfig, no ServiceAccount, no RBAC, no informers. It talks to Postgres and to whatever --provider selects, and nothing else.

The contract

Three relations and one notification channel, specified in testdata/contract.sql:

dns_zones a view: the zones this shard is authoritative for
dns_desired_endpoints a view: the records that ought to exist, one row per RRset
dns_ownership a table: the claims. The only object this binary writes.
NOTIFY dns_intent_changed tells the source to reconcile now instead of at the next --interval

The host owns the two reads. They are views, so it can restructure freely underneath as long as the column lists hold — testdata/fixture.sql is one implementation, used by the tests.

dns_ownership is the one object this binary can own, and --migrate --migrate-schema=ownership creates it. A host that would rather define it itself sets migrations.enabled: false; note then that this schema declares zone_id without a foreign key, because contract.sql names a zones table only the reference implementation has. Both definitions are create table if not exists, so if the host and the initContainer disagree, whichever runs first wins and nothing says so.

Because the domain filter is a query rather than a command-line flag, onboarding a zone needs no restart — the one behaviour here that upstream's registries cannot offer.

Ownership, precisely

A claim exists if and only if this system created the record. Absence is meaningful: it is what protects a customer's pre-existing MX, SPF and DKIM records.

ApplyChanges claims before writing and releases after, which is upstream's ordering and is deliberate. A crash between the two leaves a claim with no record — detectable, and released as an orphan on the next pass — rather than a record with no claim, which is indistinguishable from a customer's own and could never be cleaned up.

Losing a race for a claim abandons the create rather than overwriting the other owner.

Development

$ nix develop            # or `direnv allow`
$ just ci                # fmt, vet, staticcheck, tidy, unit tests, build, chart checks
$ just test-integration  # starts a throwaway Postgres via initdb
$ just image             # OCI image, streamed to stdout
$ just chart-args-check  # feed the chart's rendered args back to the binary

chart-args-check exists because the chart and the binary are separate artifacts with no compiler between them. Without it a renamed flag ships and is discovered as a CrashLoopBackOff.

The integration tests run initdb into a temporary directory and listen on a Unix socket only, so they need no daemon, no network and no container, and two test binaries cannot collide.

docs/vendoring.md records which upstream packages this depends on, at which version, and which upstream behaviours the implementation relies on. Read it before a version bump: none of these packages carries an API stability promise, and a bump is expected to break the build.

Releases

.forgejo/workflows/release.yaml builds the image with Nix and pushes it with skopeo. A push to main publishes :latest and :<short-sha>; a v* tag additionally publishes the version, so a chart can pin something that does not move underneath it.

The chart goes to oci://git.shine.town/infra/charts as an OCI artifact, deliberately not alongside the image. helm push appends the chart name, which here is also the image name, so both would write into infra/external-dns-pgregistry and a v0.2.0 tag would put the image over the chart.

Both need a registry token in the PGREGISTRY_PACKAGE_UPLOAD_KEY secret.

Status

Early. The ownership invariants are covered by integration tests against a real Postgres and a fake provider, including a check that an unclaimed record survives a planner that asks for its deletion. It has not yet run against a production zone.

License

MIT or Apache-2.0, at your option.