Skip to content
Rezha Julio
Go back

Managing DNS as Code with DNSControl

9 min read

I manage a few domains. Nothing crazy, but enough that logging into Porkbun’s web UI every time I need to change a record got old fast. Add a TXT record, typo the value, wait for propagation, realize the typo, fix it, wait again. Fifteen minutes gone for a one-line change.

The real problem is that web UIs don’t have undo. They don’t have diffs. They don’t have commit messages. I changed a DNS record last month and I genuinely could not remember what it was before. Was the TTL 300 or 3600? Did I have a CNAME there or an A record? No idea.

So I moved everything to DNSControl.

What DNSControl actually does

DNSControl is an open-source tool from StackExchange (the folks behind Stack Overflow). You define your DNS records in a dnsconfig.js file, and DNSControl talks to your DNS provider’s API to make reality match your config.

The workflow is:

  1. Edit dnsconfig.js
  2. Run dnscontrol preview to see what would change
  3. Run dnscontrol push to apply it
  4. Commit to Git

That’s it. Your DNS records are now version-controlled. If something breaks, git log tells you exactly what changed and when. git revert brings it back.

The config file

DNSControl uses JavaScript for its config. Not full Node.js, just a simple DSL that happens to use JS syntax. Here’s a stripped-down version of what mine looks like:

dnsconfig.js
var REG_CHANGEME = NewRegistrar("none");
var DSP_PORKBUN = NewDnsProvider("porkbun");
D("example.com", REG_CHANGEME,
DnsProvider(DSP_PORKBUN),
A("@", "1.2.3.4"),
A("homelab", "1.2.3.4"),
CNAME("www", "example.com."),
MX("@", 10, "mail.example.com."),
TXT("@", "v=spf1 mx ~all"),
TXT("_dmarc", "v=DMARC1; p=quarantine"),
);

Each record type has its own function. A(), CNAME(), MX(), TXT(). If you’ve ever written a DNS zone file, this reads the same way but without the confusing syntax.

You also need a creds.json file with your provider’s API keys:

creds.json
{
"porkbun": {
"TYPE": "PORKBUN",
"api_key": "pk1_...",
"secret_api_key": "sk1_..."
}
}

This file never gets committed. It goes in .gitignore immediately.

Preview before you push

The preview command is where the safety net lives. It shows you a diff of what DNSControl would do, without actually doing it:

Terminal window
$ dnscontrol preview
******************** Domain: example.com
1 correction
#1: CREATE A homelab.example.com 1.2.3.4 ttl=300

I run this obsessively. DNS mistakes propagate to caches everywhere, and depending on your TTL, you might be stuck with a bad record for hours. Seeing the diff first saves you from that.

Moving to GitHub Actions

Running dnscontrol push locally works, but I wanted it automated. Push to main, DNS updates. No manual steps.

The third-party action trap

My first attempt used wblondel/dnscontrol-action@v4.27.1. It worked fine for basic records. Then I tried to add a URL301 redirect. Porkbun supports URL forwarding natively, and I wanted to set up a redirect from an old subdomain.

The push failed with:

INFO#1: Domain "example.com" provider porkbun Error: porkbun.toReq rtype "URL301" unimplemented

I spent a while thinking this was a Porkbun API issue. It wasn’t. DNSControl added URL301 support for Porkbun in v4.30.0, but the GitHub Action I was using had v4.27.1 baked into its Dockerfile. The action’s maintainer hadn’t updated it.

This is the problem with wrapper actions. You’re at the mercy of someone else’s release schedule. The action pins a specific DNSControl version, and if that version is missing the feature you need, you’re stuck until the maintainer gets around to updating.

Using the official Docker image

The fix is to skip the wrapper and use the official DNSControl Docker image from GitHub Container Registry:

.github/workflows/dns-push.yml
name: DNS Push
on:
push:
branches:
- main
jobs:
push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Decode credentials
run: |
echo "${{ secrets.CREDS }}" | base64 -d > creds.json
- name: DNSControl preview
run: >
docker run --rm -v "$PWD:/dns" -w /dns
ghcr.io/stackexchange/dnscontrol:4.34.0 preview
- name: DNSControl push
run: >
docker run --rm -v "$PWD:/dns" -w /dns
ghcr.io/stackexchange/dnscontrol:4.34.0 push

A couple of things I tripped over while setting this up:

Image tag format. The official image tags don’t have a v prefix. Use 4.34.0, not v4.34.0. I pulled the wrong tag twice before noticing.

Credentials as a secret. I Base64-encode the entire creds.json and store it as a GitHub secret called CREDS. The workflow decodes it at runtime. This way the API keys never appear in the repo, not even in environment variables that might leak in logs.

To encode it:

Terminal window
base64 -w 0 creds.json | pbcopy # macOS
base64 -w 0 creds.json | xclip # Linux

Paste the result into your repo’s Settings > Secrets > Actions.

Running preview before push. I added a preview step before push so the action logs show exactly what changed. If the preview step fails (say, a syntax error in dnsconfig.js), the push never runs. Cheap safety net.

The ACME challenge problem

This one caught me off guard.

I use Caddy as my reverse proxy, and Caddy handles SSL certificates automatically through Let’s Encrypt. Part of that process involves creating _acme-challenge TXT records for domain validation. These records are short-lived. Caddy creates them, Let’s Encrypt reads them, and Caddy deletes them.

The conflict: DNSControl doesn’t know about these records because they’re not in dnsconfig.js. So every time I push, DNSControl sees them as unauthorized drift and tries to delete them. If Caddy happens to be in the middle of a certificate renewal, DNSControl just nuked its validation record.

Even when the timing didn’t overlap, my CI logs were full of “corrections” for records I never created:

#3: DELETE TXT _acme-challenge.example.com "some-long-token-here"

Annoying at best. Breaks certificate renewals at worst.

The fix: IGNORE()

DNSControl has an IGNORE() function that tells it to leave certain records alone:

dnsconfig.js
D("example.com", REG_CHANGEME,
DnsProvider(DSP_PORKBUN),
A("@", "1.2.3.4"),
CNAME("www", "example.com."),
MX("@", 10, "mail.example.com."),
TXT("@", "v=spf1 mx ~all"),
// Let Caddy manage ACME challenges
IGNORE("_acme-challenge", "TXT"),
);

With this in place, DNSControl pretends _acme-challenge TXT records don’t exist. It won’t create them, delete them, or complain about them. Caddy does its thing, DNSControl does its thing, and they don’t step on each other.

This pattern works for any external system that creates DNS records. If you use Kubernetes ExternalDNS, you’d ignore whatever prefix it uses. If you have a third-party email provider that manages its own DKIM records, same idea.

You can also use wildcards:

IGNORE("_acme-challenge.**", "TXT"), // All subdomains too
IGNORE("**", "TXT", "some-specific-value"), // Match by value

Splitting zones into separate files

Once I had more than two domains in dnsconfig.js, the file got long. Scrolling past 80 lines of records for one domain to find the one I actually want to edit is annoying. DNSControl supports require(), so I split each domain into its own file.

The directory structure:

dns/
├── dnsconfig.js
├── creds.json
└── zones/
├── constants.js
├── example.com.js
├── example.org.js
└── another-domain.id.js

The main dnsconfig.js becomes a table of contents:

dnsconfig.js
require("zones/constants.js")
// Porkbun domains
require("zones/example.com.js")
require("zones/example.org.js")
// Cloudflare domains
require("zones/another-domain.id.js")

I keep shared values in constants.js. IP addresses mostly. When I migrate a service to a new server, I change the IP in one place instead of hunting through every zone file:

zones/constants.js
HOMELAB_IPV4 = "1.2.3.4";
VPS_IPV4 = "5.6.7.8";
BACKUP_IPV4 = "9.10.11.12";

Then each zone file uses those variables:

zones/example.com.js
var DSP_PORKBUN = NewDnsProvider("porkbun");
var REG_CHANGEME = NewRegistrar("none");
D("example.com", REG_CHANGEME,
DnsProvider(DSP_PORKBUN),
DefaultTTL(600),
A("@", HOMELAB_IPV4),
A("homelab", HOMELAB_IPV4),
A("vpn", VPS_IPV4),
CNAME("www", "example.com."),
MX("@", 10, "mail.example.com."),
TXT("@", "v=spf1 mx ~all"),
IGNORE("_acme-challenge", "TXT"),
);

The nice thing about this setup is that each file is self-contained. When I need to update DNS for a specific domain, I open that one file. The diff in Git is clean too. A commit that says “add VPN subdomain to example.com” only touches zones/example.com.js. No noise from other domains.

I also group the require() calls by provider in the main config. Makes it easy to see which domains live where. When I eventually move a domain from Porkbun to Cloudflare, I just move the require line and update the provider in the zone file.

Things I wish I knew earlier

Test with a throwaway domain first. I tested DNSControl against my main domain on the first try. Nothing went wrong, but in hindsight that was reckless. Buy a cheap .xyz domain and experiment there.

TTL matters more than you think. When I was iterating on the config, I set my TTL to 300 seconds (5 minutes). Once everything was stable, I bumped it to 3600. Lower TTL means faster propagation of changes, but also more DNS queries hitting your provider.

dnscontrol get-zones is useful for initial setup. If you already have a bunch of records in Porkbun, you don’t need to type them all out manually. Run:

Terminal window
dnscontrol get-zones --format=js porkbun PORKBUN example.com

It dumps your existing records as a dnsconfig.js snippet. Copy, paste, clean up. Saved me a lot of manual transcription.

Keep creds.json out of your shell history too. If you’re echo-ing API keys into a file, prefix the command with a space so it doesn’t get saved to .bash_history (assuming HISTCONTROL=ignorespace is set, which it is by default on most distros).

Was it worth it?

For one domain with five records? Probably not. The web UI is faster.

But I have multiple domains, and the record count keeps growing as I add more services to the homelab. Last week I added three records in one commit for a new service. Preview, push, done. No browser tabs, no copy-pasting IPs, no wondering if I remembered to save.

The Git history is the real win. I can see exactly when I added that weird CNAME, who asked for it (past me, in the commit message), and why. When something breaks, I don’t guess. I look at the log.

DNS is one of those things where mistakes are annoying and slow to fix. Having a preview command and a git revert escape hatch makes me a lot less nervous about touching it.


Related Posts