I have too many domains, and trying to keep track of all of the individual records across multiple registrars DNS providers can be a real chore. Fortunately, I saw Robb's posts↗ about↗ using DNSControl↗ to take some of the pain out of managing his DNS records. It looked like a very useful tool so I gave it a spin and came away quite impressed.
Running a script to manage my DNS records is pretty cool - but it would be even cooler to not have to run the script myself. Applying a GitOps approach to my DNS management would enable just such an automation, while also adding in some safety/sanity checks, recording a history of changes, and providing the ability to undo a change by just reverting the relevant commit.
This post will start with a quick overview of how I set up DNSControl for local execution, and then we'll get into the GitHub Actions workflows I use for doing the heavy lifting.
Initial setup
The DNSControl docs do a good job of guiding through the setup↗ so I'm not going to rehash the install process in full.
Credentials file
A creds.json file is used to tell DNSControl how to interact with the various providers↗ that control the DNS records. There are a ton of providers with varying levels of features and support. In my case, I selected the Porkbun provider↗ as my registrar and the Bunny DNS↗ provider for the records themselves.
So my creds.json file looks like this:
{
"bunny_dns": {
"TYPE": "BUNNY_DNS",
"api_key": "$BUNNY_API_KEY"
},
"porkbun": {
"TYPE": "PORKBUN",
"api_key": "$PORKBUN_API_KEY",
"secret_key": "$PORKBUN_SECRET_KEY"
},
"none": { "TYPE": "NONE" }
}
I'm storing the sensitive credentials themselves in a separate .env file:
$ export PORKBUN_API_KEY=my-api-key
$ export PORKBUN_SECRET_KEY=my-secret-key
and so on. Setting up this way will allow Future John to commit/push the required creds.json file without actually exposing my credentials.
Capturing existing records
Now that I've got creds in place, I can use the handy-dandy get-zones utility↗ to fetch all the records in a given zone. This will make it a lot easier to start using DNSControl for managing my DNS without having to recreate all the records from scratch.
The syntax for the command is
$ dnscontrol get-zones [options] <credkey> <zone>
where
<credkey>is the key of the provider configuration increds.json(likebunny_dns),<provider>is the provider name or a literal-to pull that from the creds file, and<zone>is the zone (domain name) to retrieve.
There are a few options that can be used but the most important (for this use case, at least) is probably the --format format to choose how the output should be structured; --format=djs will match the syntax of DNSControl's configuration file so that's a good choice for bulk-importing records.
So retrieving the records for the domain you're (probably) looking at right now might look a bit like this:
$ dnscontrol get-zones --format=djs bunny_dns - runtimeterror.dev
var DSP_BUNNY_DNS = NewDnsProvider("bunny_dns");
var REG_CHANGEME = NewRegistrar("none");
D("runtimeterror.dev", REG_CHANGEME
, DnsProvider(DSP_BUNNY_DNS)
, DefaultTTL(3600)
//, NAMESERVER("kiki.bunny.net.")
//, NAMESERVER("coco.bunny.net.")
, A("bearlytics", "192.0.2.1")
, A("gist", "192.0.2.1")
, A("notes", "192.0.2.1")
, A("status", "192.0.2.1")
// NOTE: CNAME at apex may require manual editing.
, CNAME("@", "rt-site.b-cdn.net.")
, CNAME("cdn", "runtimeterror-pull.b-cdn.net.")
, CNAME("www", "runtimeterror.dev.")
)
This will still need some minor adjustments before it's ready for use, but it makes a great starting point. So I just copied that output into a new file called dnsconfig.js and made a few tweaks:
- Adjust the
vardeclarations at the top to match the providers increds.json. - Use
REG_NONEas the registrar provider on all records; no reason to ping the registrar on each run unless/until I need to change the nameservers. - For that reason I'll also leave the
NAMESERVER()lines commented out. - Use a duration string (
1h) in place of the 3600-second TTL. - Use an
ALIASrecord↗ in place of theCNAMEat the zone apex (@) (after consulting the providers chart↗to confirm thatALIASis supported with Bunny DNS).
So my dnsconfig.js looks like this:
1// dnsconfig.js: dnscontrol configuration
2
3// Providers:
4var DSP_BUNNY_DNS = NewDnsProvider("bunny_dns");
5var REG_PORKBUN = NewRegistrar("porkbun");
6var REG_NONE = NewRegistrar("none");
7
8// Domains:
9D("runtimeterror.dev", REG_NONE
10 , DnsProvider(DSP_BUNNY_DNS)
11 , DefaultTTL("1h")
12 // , NAMESERVER("kiki.bunny.net.")
13 // , NAMESERVER("coco.bunny.net.")
14 , A("bearlytics", "192.0.2.1")
15 , A("gist", "192.0.2.1")
16 , A("notes", "192.0.2.1")
17 , A("status", "192.0.2.1")
18 , ALIAS("@", "rt-site.b-cdn.net.")
19 , CNAME("cdn", "runtimeterror-pull.b-cdn.net.")
20 , CNAME("res", "srsbsns-web.b-cdn.net.")
21 , CNAME("www", "runtimeterror.dev.")
22)
At this point, I can circle back and run dnscontrol get-zones again for another domain, add (and tweak) that output to my dnsconfig.js file, and repeat until all my domains are accounted for:
1// dnsconfig.js: dnscontrol configuration file
2
3// Providers:
4var DSP_BUNNY_DNS = NewDnsProvider("bunny_dns");
5var DSP_PORKBUN = NewDnsProvider("porkbun");
6var REG_PORKBUN = NewRegistrar("porkbun");
7var REG_NONE = NewRegistrar("none");
8
9// Domains:
10D("jwq.lol", REG_NONE
11 , DnsProvider(DSP_BUNNY_DNS)
12 , DefaultTTL("1h")
13 // , NAMESERVER("kiki.bunny.net.")
14 // , NAMESERVER("coco.bunny.net.")
15 , A("bearlytics", "192.0.2.1")
16 , ALIAS("@", "hosted.omg.lol.")
17 , CNAME("cdn", "cdn-jwqlol.b-cdn.net.")
18 , CNAME("chillfeed", "jwq.github.io.")
19 , CNAME("now", "hosted.omg.lol.")
20 , CNAME("paste", "hosted.omg.lol.")
21 , CNAME("res", "srsbsns-web.b-cdn.net.")
22 , CNAME("status", "hosted.omg.lol.")
23 , CNAME("url", "hosted.omg.lol.")
24)
25
26D("runtimeterror.dev", REG_NONE
27 , DnsProvider(DSP_BUNNY_DNS)
28 , DefaultTTL("1h")
29 // , NAMESERVER("kiki.bunny.net.")
30 // , NAMESERVER("coco.bunny.net.")
31 , A("bearlytics", "192.0.2.1")
32 , A("gist", "192.0.2.1")
33 , A("notes", "192.0.2.1")
34 , A("status", "192.0.2.1")
35 , ALIAS("@", "rt-site.b-cdn.net.")
36 , CNAME("cdn", "runtimeterror-pull.b-cdn.net.")
37 , CNAME("res", "srsbsns-web.b-cdn.net.")
38 , CNAME("www", "runtimeterror.dev.")
39)
40
41D("example.com", REG_NONE
42 , DnsProvider(DSP_BUNNY_DNS)
43 , DefaultTTL("1h")
44 // , NAMESERVER("kiki.bunny.net.")
45 // , NAMESERVER("coco.bunny.net.")
46 , A("bearlytics", "192.0.2.1")
47 , A("goto", "192.0.2.1")
48 , A("status", "192.0.2.1")
49 , ALIAS("@", "domain-proxy.bearblog.dev.")
50 , CNAME("cabin", "custom.withcabin.com.")
51 , CNAME("cdn", "cdn-srsbsns.b-cdn.net.")
52 , CNAME("fe-bounces", "forwardemail.net.")
53 , CNAME("res", "srsbsns-web.b-cdn.net.")
54 , CNAME("www", "domain-proxy.bearblog.dev.")
55)
Previewing changes
Now that my config is in order I can use the dnscontrol preview command to preview any changes. This is useful both to confirm that my local config matches what's in production as well as to validate any changes.
$ dnscontrol preview
******************** Domain: jwq.lol
******************** Domain: runtimeterror.dev
******************** Domain: example.com
Done. 0 corrections.
So in this case everything seems to match up. Let's make a few changes and see what preview reports:
1// dnsconfig.js: dnscontrol configuration file
2
3// Providers:
4var DSP_BUNNY_DNS = NewDnsProvider("bunny_dns");
5var DSP_PORKBUN = NewDnsProvider("porkbun");
6var REG_PORKBUN = NewRegistrar("porkbun");
7var REG_NONE = NewRegistrar("none");
8
9// Domains:
10D("jwq.lol", REG_NONE
11 , DnsProvider(DSP_BUNNY_DNS)
12 , DefaultTTL("1h")
13 // , NAMESERVER("kiki.bunny.net.")
14 // , NAMESERVER("coco.bunny.net.")
15 , A("bearlytics", "192.0.2.1")
16 , A("dnscontrol", "192.0.2.1")
17 , ALIAS("@", "hosted.omg.lol.")
18 , CNAME("cdn", "cdn-jwqlol.b-cdn.net.")
19 , CNAME("chillfeed", "jwq.github.io.")
20 , CNAME("now", "hosted.omg.lol.")
21 , CNAME("paste", "hosted.omg.lol.")
22 , CNAME("res", "srsbsns-web.b-cdn.net.")
23 , CNAME("status", "hosted.omg.lol.")
24 , CNAME("url", "hosted.omg.lol.")
25)
26
27D("runtimeterror.dev", REG_NONE
28 , DnsProvider(DSP_BUNNY_DNS)
29 , DefaultTTL("1h")
30 // , NAMESERVER("kiki.bunny.net.")
31 // , NAMESERVER("coco.bunny.net.")
32 , A("bearlytics", "192.0.2.1")
33 , A("gist", "192.0.2.1")
34 , A("notes", "192.0.2.1")
35 , A("status", "192.0.2.1")
36 , ALIAS("@", "rt-site.b-cdn.net.")
37 , CNAME("cdn", "runtimeterror-pull.b-cdn.net.")
38 , CNAME("dnscontrol", "dnscontrol.jwq.lol.")
39 , CNAME("res", "srsbsns-web.b-cdn.net.")
40 , CNAME("www", "runtimeterror.dev.")
41)
42
43D("example.com", REG_NONE
44 , DnsProvider(DSP_BUNNY_DNS)
45 , DefaultTTL("1h")
46 // , NAMESERVER("kiki.bunny.net.")
47 // , NAMESERVER("coco.bunny.net.")
48 , A("bearlytics", "192.0.2.1")
49 , A("goto", "192.0.2.1")
50 , A("status", "192.0.2.1")
51 , ALIAS("@", "domain-proxy.bearblog.dev.")
52 , CNAME("cabin", "custom.withcabin.com.")
53 , CNAME("cdn", "cdn-srsbsns.b-cdn.net.")
54 , CNAME("fe-bounces", "forwardemail.net.")
55 , CNAME("res", "srsbsns-web.b-cdn.net.")
56 , CNAME("www", "domain-proxy.bearblog.dev.")
57 , TXT("dnscontrol", "look at me go")
58)
$ dnscontrol preview
******************** Domain: jwq.lol
1 correction (bunny_dns)
#1: + CREATE dnscontrol.jwq.lol A 192.0.2.1 ttl=3600
******************** Domain: runtimeterror.dev
1 correction (bunny_dns)
#1: + CREATE dnscontrol.runtimeterror.dev CNAME dnscontrol.jwq.lol. ttl=3600
******************** Domain: example.com
1 correction (bunny_dns)
#1: + CREATE dnscontrol.example.com TXT "look at me go" ttl=3600
Done. 3 corrections.
As expected, it picked up the three records I added.
Executing changes
All I need to do to actually push these changes is alter the command from dnscontrol preview to dnscontrol push:
$ dnscontrol push
******************** Domain: jwq.lol
1 correction (bunny_dns)
#1: + CREATE dnscontrol.jwq.lol A 192.0.2.1 ttl=3600
SUCCESS!
******************** Domain: runtimeterror.dev
1 correction (bunny_dns)
#1: + CREATE dnscontrol.runtimeterror.dev CNAME dnscontrol.jwq.lol. ttl=3600
SUCCESS!
******************** Domain: example.com
1 correction (bunny_dns)
#1: + CREATE dnscontrol.example.com TXT "look at me go" ttl=3600
SUCCESS!
Done. 3 corrections.
Bunny really hops so those changes are live pretty much instantaneously:
$ dig +short dnscontrol.jwq.lol A
192.0.2.1
$ dig +short dnscontrol.runtimeterror.dev CNAME
dnscontrol.jwq.lol.
$ dig +short dnscontrol.example.com TXT
"look at me go"
If I decide that I don't actually want those records, I can just remove them from dnscontrol.js and re-rerun the push command:
$ dnscontrol push
******************** Domain: jwq.lol
1 correction (bunny_dns)
#1: - DELETE dnscontrol.jwq.lol A 192.0.2.1 ttl=3600
SUCCESS!
******************** Domain: runtimeterror.dev
1 correction (bunny_dns)
#1: - DELETE dnscontrol.runtimeterror.dev CNAME dnscontrol.jwq.lol. ttl=3600
SUCCESS!
******************** Domain: example.com
1 correction (bunny_dns)
#1: - DELETE dnscontrol.example.com TXT "look at me go" ttl=3600
SUCCESS!
Done. 3 corrections.
GitHub Actions workflows
Alright, now for the fun part: setting up a GitHub Actions workflow to do the work for me. I'm going to use a private GitHub repository for this since I don't necessarily want everyone to know about all of the domains I own.
Repository structure
In that repo, I'll add the dnsconfig.js and creds.json files I've been working with so far (remember: creds.json references environment variables which store the credentials rather than storing the credentials directly). I also include a .gitignore which tells git to ignore the .env file which actually stores the creds so they won't be accidentally committed to GitHub. Those credentials will instead be stored as repository secrets↗ to make them available to the workflows.
The layout looks something like this:
.
├── .env
├── .github
│ └── workflows
│ ├── preview.yaml
│ └── push.yaml
├── .gitignore
├── creds.json
└── dnsconfig.js
As you can see, the repo will also hold a pair of workflows: preview.yaml and push.yaml.
Preview workflow
The preview workflow gets triggered by a pull request. It uses dnscontrol-action↗ to validate the syntax of the configuration and perform a preview run, and leverages comment-on-pr↗ to add a comment on the PR to indicate which record(s) will be impacted.
1name: Check and Preview
2
3on: # execute on pull request event
4 pull_request:
5
6# permissions needed to write a comment to the PR
7permissions:
8 contents: read
9 issues: write
10 pull-requests: write
11
12jobs:
13 preview:
14 runs-on: ubuntu-latest
15 steps:
16 # check out the repo
17 - uses: actions/checkout@v4
18
19 # validate dnscontrol.js syntax
20 - name: DNSControl check
21 uses: is-cool-me/dnscontrol-action@v4.13.0
22 with:
23 args: check
24
25 # execute preview run
26 - name: DNSControl preview
27 uses: is-cool-me/dnscontrol-action@v4.13.0
28 id: dnscontrol_preview
29 env:
30 BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
31 PORKBUN_API_KEY: ${{ secrets.PORKBUN_API_KEY }}
32 PORKBUN_SECRET_KEY: ${{ secrets.PORKBUN_SECRET_KEY }}
33 # disable terminal color codes in the output to keep comments clean
34 NO_COLOR: true
35 with:
36 args: preview
37
38 # insert preview output as PR comment
39 - name: Preview pull request comment
40 uses: unsplash/comment-on-pr@v1.3.0
41 env:
42 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43 with:
44 # fences should be ``` (no spaces) but escaping that is hard
45 # use your imagination please
46 msg: |
47 ` ` `
48 ${{ steps.dnscontrol_preview.outputs.preview_comment }}
49 ` ` `
50 check_for_duplicate_msg: true
Push workflow
The push workflow pushes the changes to the DNS provider(s), and will be executed after I've reviewed the preview results and decided to complete the merge.
1name: Push
2
3on:
4 push:
5 branches:
6 - main
7
8jobs:
9 push:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v4
13
14 - name: DNSControl push
15 uses: is-cool-me/dnscontrol-action@v4.13.0
16 env:
17 BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
18 PORKBUN_API_KEY: ${{ secrets.PORKBUN_API_KEY }}
19 PORKBUN_SECRET_KEY: ${{ secrets.PORKBUN_SECRET_KEY }}
20 with:
21 args: push
Usage
Now that the workflows are in place, let's see how I use them to manage my DNS records.
I start by making sure my local repo is up to date, and then creating a new branch to work on:
$ git switch main
$ git pull
$ git switch -c demo
And I'll update dnscontrol.js to create the same records I was playing with earlier:
1// dnsconfig.js: dnscontrol configuration file
2
3// Providers:
4var DSP_BUNNY_DNS = NewDnsProvider("bunny_dns");
5var DSP_PORKBUN = NewDnsProvider("porkbun");
6var REG_PORKBUN = NewRegistrar("porkbun");
7var REG_NONE = NewRegistrar("none");
8
9// Domains:
10D("jwq.lol", REG_NONE
11 , DnsProvider(DSP_BUNNY_DNS)
12 , DefaultTTL("1h")
13 // , NAMESERVER("kiki.bunny.net.")
14 // , NAMESERVER("coco.bunny.net.")
15 , A("bearlytics", "192.0.2.1")
16 , A("dnscontrol", "192.0.2.1")
17 , ALIAS("@", "hosted.omg.lol.")
18 // [...]
19)
20
21D("runtimeterror.dev", REG_NONE
22 , DnsProvider(DSP_BUNNY_DNS)
23 , DefaultTTL("1h")
24 // , NAMESERVER("kiki.bunny.net.")
25 // , NAMESERVER("coco.bunny.net.")
26 // [...]
27 , ALIAS("@", "rt-site.b-cdn.net.")
28 , CNAME("cdn", "runtimeterror-pull.b-cdn.net.")
29 , CNAME("dnscontrol", "dnscontrol.jwq.lol.")
30 , CNAME("res", "srsbsns-web.b-cdn.net.")
31 , CNAME("www", "runtimeterror.dev.")
32)
33
34D("example.com", REG_NONE
35 , DnsProvider(DSP_BUNNY_DNS)
36 , DefaultTTL("1h")
37 // , NAMESERVER("kiki.bunny.net.")
38 // , NAMESERVER("coco.bunny.net.")
39 // [...]
40 , CNAME("res", "srsbsns-web.b-cdn.net.")
41 , CNAME("www", "domain-proxy.bearblog.dev.")
42 , TXT("dnscontrol", "look at me go")
43)
I save the file, stage it, and then commit it:
$ git add dnscontrol.js
$ git commit -S -m "add demo records"
And then I push my new local branch to a new branch on the remote:
$ git push origin demo
Now it's time to jump out of the terminal and go check out my (private) repo on GitHub, where I get a big friendly banner about how the demo branch just had a recent push and would I like to Compare & pull request.
I click the big green button to start the pull request, and review the changes on the next screen to make sure there aren't any surprises. Satisfied, I click the next big green Create pull request button.
That triggers the preview workflow, and after a few moments a comment gets added to the PR thread describing the expected changes:
Those are exactly the changes I intended to make so I can click the green Merge pull request button at the bottom of the page to accept the change - and trigger the push workflow to execute. I click over to the Actions tab to make sure the run is successful:
And I can also check from my trusty terminal:
$ dig +short dnscontrol.example.com TXT
"look at me go"
$ dig +short dnscontrol.runtimeterror.dev CNAME
dnscontrol.jwq.lol.
$ dig +short dnscontrol.jwq.lol A
192.0.2.1
So what?
I'll be honest: I probably could have logged into Bunny's web interface and created those records the old-fashioned click-ops way quicker than doing the whole edit file -> stage file -> commit file -> git push -> create pull request -> merge pull request dance. So why bother?
DNS is a pretty critical piece of any environment, and it can be kind of easy to screw up with a careless typo or a click in the wrong place. I've definitely lost my place a time or two when working on an information-dense UI and edited (or worse, deleted) the row above or below the one I was trying to change. This is an even bigger risk when working with multiple providers with their own similar-but-different interfaces and layouts.
A methodical text-based approach like DNSControl makes careless mistakes less likely, and it provides a standard approach which works the same regardless of what providers are being used. One of my first big wins with DNSControl was using it to migrate three complete zones (domains) from one provider to another. Rather than needing to manually pre-stage all of the existing records (10-15 records including A, CNAME, TXT, and MX for each domain) in the new provider and then update the name server configuration with the registrar, all I had to do was adjust the DnsProvider() line for each zone. DNSControl took care of creating all the records and swapping the name server configuration. The entire migration was done in under a minute. (And, thanks to Past John using DNSControl to update the TTLs on all the records the day before the migration, the changes were actually live in under five minutes. That Past John guy can be pretty smart, sometimes.)
Adding the GitOps approach to the mix provides additional sanity checks through PR reviews. Even with an organization of one, being forced asked to look at what you've done before the changes get applied can provide a valuable opportunity to catch mistakes. This approach also creates a historical record of changes, making it easy to see what changed (and when). It also allows for painless roll-backs in case of trouble... or if you just created a couple of demo records for a blog post and don't need them anymore.




