DNS management in a monorepo
Buying a domain is one of the best purchases you can make because they are very cheap and owning one offers you more independence - you can have your email not tied to a specific provider, you can have a site, etc.
After I bought a domain, I needed a way to manage DNS of that domain. Since using the registrar’s UI to do that is not the best idea, I searched for an IaC tool that can do that for me - I settled on DNSControl.
Single-file config
Initially, I just needed to set up email and GitHub Pages, so I used the most basic setup imaginable.
infra/dns/
├── dnsconfig.js # DNS config
├── creds.json.tpl # Credential config
└── zones/
└── alwaldend.com.zone # BIND file
I just wrote records in DNSControl’s subset of JavaScript and templated secrets from the environment using envsubst. It worked alright, and DNSControl rendered a BIND file so I could see the computed DNS records as text.
Single-file config with secret injection
At some point, I migrated my secrets to HashiCorp Vault because storing secrets in environment variables is a big no-no. That didn’t change the layout. The credential config just became a Go template that used secrets from Vault.
Per-project config
Once I started setting up infrastructure, I hit an annoying limitation - extracting records from .js is extremely inconvenient, which means that you have to duplicate IPs for VMs instead of just extracting them from the config. And managing all DNS records in a single .js file is not very convenient either. To solve it, I split the main config into several static per-project configs and then just parsed them in the main .js file.
The layout became like this:
infra/dns/ # The main DNS project
infra/project/ # Another infra project
├── dnsconfig.json # Static DNS config
And the config looked like this:
{
"domains": {
"default": {
"records": {
"host1_a": {
"A": {
"name": "host1.consul1.dc1",
"address": "192.168.10.20"
}
}
}
}
}
}
This was a bad config design as will become clear later.
Split-horizon DNS
Later, I decided to set up Split-horizon DNS so I can have different records for internal and external DNS servers. Thankfully, DNSControl supports this, so there were no problems from this side, but configs had to be redone because they grouped records by domains which did not translate well to DNSControl’s DNS providers.
I simplified the config and added dsp fields to select which DNS provider the record belongs to.
{
"records": {
"host1": {
"A": {
"name": "host1.threexui",
"address": "192.168.10.80"
},
"dsp": ["dc1"]
}
}
}
Terraform
That setup worked fine for a while, but then I stumbled upon an obvious flaw during a clanker session - there is no way to work in parallel (without mucking around with IGNORE and NO_PURGE). DNSControl controls the whole zone, so if you have different sessions applying different configs, then those changes override each other.
That seems like an easy problem to fix - just split the domain into different zones, and it was easy for my local DNS (a MikroTik router) because DNSControl’s provider has zone hints, but there is no way to split the domain on the external DNS (Cloudflare) without buying an Enterprise plan or without delegating to yet another DNS provider.
I thought about my options and decided to just switch to Terraform because I already have a standard way of handling Terraform in the repo. This approach fixed the problem, because now projects own only their own DNS records, so you can work on them in parallel.
To implement DNS control in Terraform, I wrote a Terraform module which parses the config and updates DNS, then used it in each project. I also added a linter that checks that projects do not overlap DNS records. The final layout looks like this:
projects/
├── project name/
│ ├── dnsconfig.json # DNS config, unchanged
│ └── tf/ # A Terraform directory
└── tf_modules/dns_records/ # Reusable Terraform module
infra/dns/
├── dnsconfig.json # Generic DNS like email
├── tf/ # A Terraform directory
└── cmd/lint/ # A linter that checks configs for collisions
Future
There are no restrictions on DNS management - each project uses the same API keys and can modify the whole domain. This is not ideal, of course, but it’s not that big of a deal because I trust all developers involved (Me and clankers) to not fuck it up. I might revisit this problem in the future, but for now this setup is good enough.
Slop without a clear goal
A while back, I said to Jipity1: “Jipity, how can I improve agent ergonomics in this monorepo?”, and Jipity gave me some useful suggestions, but one of them was not like the rest. It said that context in the repo was too splintered — agents had to read many things in different directories to understand how things are done. In my infinite wisdom, I said, “Jipity, write me a comprehensive plan implementing a unified agent system with the goal of improving ergonomics in this monorepo”. And it wrote me a plan, I did not really read it.
The plan consisted of several phases which gradually added more agent ergonomics improvements. Over the course of several days I made several PRs implementing the plan. In each PR, agents added things I did not understand according to the plan I did not read. After I prompted them to explain and still did not understand, I gave up and decided to just “trust the plan”, and merged everything. After implementing the plan, my repo was littered with garbage manifests, I had a lot of new code, and nothing really changed.
I decided that maybe I did not trust the plan enough, and did a couple more phases of “ergonomics improvements”, which also did nothing but increase line count and litter the repo with garbage. Overall, I got around crisp 30k lines of new code.
30k of code is not much in the grand scheme of things, but I don’t like that my pristine monorepo had 30k lines of pointless slop, so I’ve deleted the “agent system”.
The lesson here is that you need to have a clear idea of what you are trying to accomplish, I guess.
Data
Jipity made me a table, seems about right.
| Date | Timeline item | Code files | Non-code files | Code LOC | Non-code LOC | Total LOC |
|---|---|---|---|---|---|---|
| 2026-08-31 | Phase 0 — System contract | 4 | 39 | 21 (+21/−0) | 6,908 (+6,825/−83) | 6,929 |
| 2026-08-31 | Phase 1 — Shared semantics and controls | 32 | 58 | 2,661 (+2,456/−205) | 2,779 (+2,736/−43) | 5,440 |
| 2026-09-01 | Phases 2–3 — Context, recovery, planning, and evidence | 115 | 73 | 18,018 (+17,850/−168) | 3,121 (+2,980/−141) | 21,139 |
| 2026-09-02 | Phase 4 — Connect work, delivery, and release | 26 | 19 | 2,996 (+2,883/−113) | 566 (+521/−45) | 3,562 |
| 2026-09-02 | Phase 5 — Learning contracts | 21 | 16 | 1,524 (+1,523/−1) | 311 (+258/−53) | 1,835 |
| 2026-09-03 | Phase 5 follow-up — Exercise the learning workflow | 25 | 27 | 914 (+883/−31) | 1,043 (+1,007/−36) | 1,957 |
| 2026-09-03 | Phase 6 — Reduce workflow friction | 28 | 69 | 1,198 (+1,101/−97) | 1,206 (+793/−413) | 2,404 |
| 2026-09-05 | System simplification | 41 | 49 | 5,037 (+4,548/−489) | 4,714 (+2,293/−2,421) | 9,751 |
| Overall | 8 commits | 292 | 350 | 32,369 (+31,265/−1,104) | 20,648 (+17,413/−3,235) | 53,017 |
-
GPT 5.6 Sol. ↩︎