Home / Stories / DNS as code
Story · Config as code Roadmap

Retire the load balancer. The DNS records go in the same wave.

You retire an ingress controller (the component that routes outside traffic into the cluster) and release its address. Three DNS records still point at that address, so they are proxied and return errors. Nobody notices until someone sweeps every zone by hand. Now put DNS in git and add an agent that checks every record against what actually runs. Then those records become a draft merge request (MR) next to the retirement. The design of this lane is written, and a first read-only check of the records is in development. The read-only Cloudflare access that it builds on is live.

Starts fromCloudflare DNS for every account and zone. You import it into a git repository, and CI applies changes.
Looks atRecords in the repository and in Cloudflare, live Ingress / IngressRoute / HTTPRoute hosts, and LoadBalancer addresses on each cluster.
You getDraft MRs to delete or repoint dangling records in zones you let us manage. Issues for zones you manage yourself.
01 · The retirement

An old ingress controller goes, and its address is released.

A migration wave moves the last routes from the old controller to the new one. Then your team removes the old controller, and its static load-balancer address goes back to the cloud provider.

The Kubernetes side is clean. DNS lives somewhere else.

02 · What gets left behind

Three records, still pointing at the freed address.

The records are proxied. So Cloudflare keeps answering for them and returns errors to visitors. Nothing in the cluster knows that the records exist.

Without this lane, people find these records in a one-off sweep. They check every account and every zone. Then they match hundreds of records by hand against the addresses that they release.

03 · DNS in git Roadmap

One repository, imported from live, applied by CI.

You import every account and zone from Cloudflare, so nobody writes them by hand. The repository records which objects it declares and which objects it only observed. Merge requests show a plan, and the main branch applies it.

The write token lives only in CI. The assistant keeps its read-only Cloudflare tokens, the same tokens that its Cloudflare posture review already uses in production.

04 · The join Roadmap

Every record against every route and every address.

A rule-based agent does this join, with no AI model (LLM) involved. It compares the records in the repository and the live records with two things. The first is the hosts that live Ingress, IngressRoute and HTTPRoute objects declare. The second is the LoadBalancer addresses on each cluster.

It only looks at A, AAAA and CNAME records. Otherwise a verification TXT record on the same name would mask the address record and produce false findings.

05 · Four buckets Roadmap

Each mismatch has one meaning and one next step.

  • Dangling: the record points at a retired or absent address.
  • No route: the record points at a live load balancer, but no route declares the host.
  • No record: a route declares a host that nobody can reach.
  • Unknown address: the address belongs to neither a cluster nor a known third party.
06 · Your zone, your call Roadmap

Managed zones get MRs. Advisory zones get issues.

Each account or zone has an explicit management mode. Managed: the DNS repository is authoritative for what it declares. CI holds a write token. The assistant proposes draft MRs. Advisory: you manage the zone yourself. The assistant only reads it and opens issues.

The mode decides whether the assistant opens an MR or an issue. The mere presence of a token never decides it.

07 · Out-of-band edits Roadmap

Declared means managed. Everything else is left alone.

Proxy, page-rule and Access settings can live in the repository, but not exclusively. The assistant reports an object that the repository does not declare as drift to review. It never proposes to delete that object, and the apply never purges it.

A dashboard edit to a declared record is real drift. The assistant opens an MR to accept the live value, or an issue to discuss it.

08 · Same wave Roadmap

The retirement MR names the records it orphans.

When an MR retires a host or a load balancer, the DNS lane compares the retired address with the records. The MR then cites the records that the retirement would leave dangling, with a link to the matching DNS MR. So the cleanup is part of the change, not a sweep months later.

09 · The proposal

Delete three records, with the proof that nothing serves them.

The diff removes three records from the DNS repository. The MR body shows why each record is dangling. It shows what the CI plan will do, and what to check after the apply. The full MR is below.

Merged · Retire the legacy ingress controller
- infrastructure/ingress-legacy/helmrelease.yaml
- infrastructure/ingress-legacy/service.yaml
  loadBalancerIP: 203.0.113.10   # released
legacy controller0 routes
new controllerall routes
203.0.113.10released
cluster: clean · Flux: Ready
Cloudflare · acme.example · A / CNAME
nametypecontentproxied
shopA203.0.113.20yes
loginA203.0.113.10yes
apiA203.0.113.20yes
previewA203.0.113.10yes
grafana-oldA203.0.113.10yes
wwwCNAMEshop.acme.exampleyes
3 proxied records → a released address · found by a manual sweep
dns/zones/acme.example.yaml · imported from live
zone: acme.example
mode: managed
records:
  - { name: shop, type: A, content: 203.0.113.20, proxied: true }
  - { name: login, type: A, content: 203.0.113.10, proxied: true }
  - { name: www,  type: CNAME, content: shop.acme.example }
observed_only:
  - page rule "legacy-redirect"   # not declared, never purged
MRplan in CI
mainCI apply · write token
assistantread-only tokens
DNS recordsrepo ∪ live · A/AAAA/CNAME
Route hostsIngress · IngressRoute · HTTPRoute
LB addressesper cluster
join · no LLM
✓ shop.acme.example      → 203.0.113.20 → route shop/storefront
✕ login.acme.example     → 203.0.113.10 → no load balancer
· _verify.shop  TXT      ignored: not an address record
Dangling→ retired address · MR: delete or repoint
No route→ live LB, no host · issue: controller returns 404
No recordroute with no DNS · issue: unreachable route
Unknown addressnot ours, not a known provider · issue: identify
this wave: 3 dangling · 0 no route · 1 no record · 0 unknown
Finding: 3 dangling records
acme.example · the zone's mode decides the surface
mode: manageddraft MR to the DNS repo
mode: advisoryissue only · no write path
a write token alone never turns an advisory zone into a managed one
repo ↔ live · acme.example
  • api · A · declared 203.0.113.20, live 203.0.113.21 declared drift → MR or issue
  • page rule "legacy-redirect" not declared → reported, never deleted
  • Access app "admin" not declared → reported, never deleted
Draft · Retire the legacy ingress controller · note
Ingress MRretires the address
DNS MRremoves the records
Draft · Remove 3 records pointing at retired 203.0.113.10
-  - { name: login,       type: A, content: 203.0.113.10, proxied: true }
-  - { name: preview,     type: A, content: 203.0.113.10, proxied: true }
-  - { name: grafana-old, type: A, content: 203.0.113.10, proxied: true }
category:reliabilitysource:dnsrisk:low
↓ the full merge request is below

The merge request you would get.

This MR is illustrative and on the roadmap. The record format is neutral on purpose. The design uses one record format for every DNS provider, with Cloudflare first.

Draft

Remove 3 DNS records pointing at retired address 203.0.113.10

from gitops-ai/dns-dangling-acme-example into main · 1 file · +0 −3
category:reliabilitysource:dnsrisk:lowxrev::LGTM
OverviewEvidencePlan & checksChanges
Summary

Three proxied A records in acme.example point at 203.0.113.10. This is the load-balancer address of the legacy ingress controller that the companion MR retires. No live load balancer holds that address. No route declares these hosts. Visitors currently get an error page.

Why delete, not repoint

No live route on any cluster declares any of the three hosts. So there is no correct target to repoint to. If one of them must stay, repoint it to 203.0.113.20 and add a route for it instead.

Zone mode

acme.example is managed: the repository is authoritative for the records it declares. The MR changes only these three declared records. It purges nothing undeclared.

RecordPoints atLoad balancerRoute declaring the host
login203.0.113.10none (released)none on any cluster
preview203.0.113.10none (released)none on any cluster
grafana-old203.0.113.10none (released)none on any cluster

The assistant joined only A, AAAA and CNAME records. It read the routes and addresses of every cluster completely. A partial read would have produced an issue instead of this MR.

What the CI plan should show
  • 3 deletions in acme.example, no creations and no modifications.
  • No changes to page rules, Access applications or zone settings.
After the apply, expect
  • dig login.acme.example returns NXDOMAIN.
  • The next DNS reconciliation reports 0 dangling records for this zone.
Rollback

Revert this MR. CI then recreates the three records as they were.

   records:
     - { name: shop,           type: A,     content: 203.0.113.20, proxied: true }
-    - { name: login,          type: A,     content: 203.0.113.10, proxied: true }
     - { name: api,            type: A,     content: 203.0.113.20, proxied: true }
-    - { name: preview,        type: A,     content: 203.0.113.10, proxied: true }
-    - { name: grafana-old,    type: A,     content: 203.0.113.10, proxied: true }
     - { name: www,            type: CNAME, content: shop.acme.example }
R
independent reviewer second opinion, no pipeline context

Confirmed: the address is no longer allocated, and no route declares the three hosts. The diff touches only declared records. LGTM.

What keeps it honest.

DNS mistakes become public within minutes. The design relies on read-only access and explicit ownership.

Read-only assistantThe assistant never holds a DNS write token. CI applies what a human merged.
Declared-only deletesThe repository is authoritative only for what it declares. The assistant reports objects that someone manages out-of-band as drift. It never purges them.
Mode, not tokenAn explicit setting for each account or zone decides whether a zone gets MRs or issues. The assistant does not infer it from the credentials that exist.
Address records onlyThe join ignores TXT and other record types that share a name. Those records caused false findings in the manual sweeps.
Partial reads abstainIf the assistant cannot read a cluster or zone completely, it does not claim "no route". Unknown stays unknown.
Existing safety layerCloudflare findings already pass a token-echo scrub and an exact-match exempt list before the assistant sends them. The DNS lane reuses both.

Where this stands.

Live
  • Read-only Cloudflare access across accounts and zones
  • Cloudflare edge-posture review (issues only, rule-based)
  • Live Ingress and LoadBalancer Service reads on every cluster
In development
  • A read-only, rule-based census that sorts the records into the four buckets. It runs as a separate check, not yet in the assistant's runs, and opens no MRs or issues. The design is written, and its scope questions (customer zones, non-exclusive management, optional write tokens) have answers
Roadmap
  • DNS repository with CI apply, imported from live (our own applier, with one record format for every DNS provider)
  • Record ↔ route ↔ address reconciliation as an agent that opens MRs and issues
  • Managed / advisory mode per account or zone
  • Repository ↔ live drift reporting
  • Retirement MRs citing the records they orphan
← Previous storyAlert silences as code