Dual-plane DNS as code.
Public records and LAN names used to live in two places. They drifted. This repo is the single change that updates both.
The problem
Homelab and small-org DNS usually splits in two: public records at a cloud provider, and LAN names in a local resolver. A hostname works on the internet and fails on wifi, or the reverse. The fix is not another UI — it is one Git repo that owns both planes.
The model
Each domain is a folder of tiny Terraform files. The zone lives in one file. Each public record is its own file so a pull request is a readable diff, not a 400-line zone dump. LAN names live in a routes file next to them. Addresses come from named pools rather than scattered literals.
Four modules keep the API small: create a Cloud DNS zone, write a public record, write a Pi-hole A record, or attach the standard Google Workspace MX set. A name that should resolve on both planes is declared twice — same subdomain, different module.
Cloud DNS
Public plane. A, CNAME, TXT, SRV, MX — authoritative for the internet.
Pi-hole
LAN plane. One A record per name. Same Git change can dual-write.
Delivery
Pull requests run terraform plan on a self-hosted runner so the job can reach the LAN resolver. Merge to main applies. Google Cloud uses Workload Identity Federation — no long-lived JSON keys in CI. After a zone is created, registrar name servers come from Terraform output.
Why this shape
DNS changes are high-blast-radius and low-frequency. Folder and file granularity makes review the default. Dual-write makes “works on LAN, broken in public” a missing module call, not a tribal-knowledge gap. Named pools mean rotating a cluster address is one locals change, not a hunt through every zone.
Same name, both planes
module "app" {
source = "../../modules/cloud-record"
zone = module.zone
subdomain = "app"
type = "A"
rrdatas = var.ips.cluster_a
}
module "local_app" {
source = "../../modules/local-record"
zone = module.zone
subdomain = "app"
rrdatas = var.ips.cluster_a
}
← Projects