Version controlled DNS with DNSControl

Managing DNS in provider dashboards is slow, inconsistent, and error-prone - bulk edits, drift, and missing audit trails make it worse. Why not keep DNS records with your application code so changes are reviewed, versioned, and deployed automatically?

Version controlled DNS with DNSControl
Photo by Kelvin Ang / Unsplash

Managing DNS through provider dashboards is often a slow and error-prone experience. Whether you use one provider or several, the UI is rarely optimised for bulk changes, consistency or speed, and accidental edits, silent drift and the lack of audit trails can be especially painful.

Wouldn’t it be better if DNS records lived alongside your application code, with changes reviewed, versioned and automatically deployed?

This article walks through using DNSControl, Git, and GitHub Actions to manage DNS across multiple zones and providers (primarily Cloudflare), using JavaScript configuration files. This is the workflow I use both personally and at work, where colleagues can safely propose changes without needing credentials or access to DNS provider dashboards.

Why DNSControl?

DNSControl, built by StackExchange, brings infra-as-code principles to DNS:

  • Version control: every change is reviewable and reversible.
  • Multi-provider support: manage Cloudflare, Name.com, Bind and many others from one repo.
  • Preview and apply flows: see the delta before making changes.
  • Consistent APIs: avoid dashboard-specific quirks and limitations.
  • Safer collaboration: no shared logins or risky manual edits.

Once configured, making DNS changes becomes as simple as opening a pull request.

Prerequisites

You need a working dnscontrol binary. The Getting Started guide provides a few options - and while running it through Docker is appealing and potentially convient for portability, I opted to install the binary directly:

Download binaries from GitHub for Linux (binary, tar, RPM, DEB), FreeBSD (tar), Windows (exec, ZIP) for 32-bit, 64-bit, and ARM.

Alternatively you can build and install with Go:

go install github.com/StackExchange/dnscontrol/v4@latest

If you intend to automate deployments, you’ll also want a GitHub repository. However, everything in this article also works locally without GitHub.

Initial Setup

Create a dnsconfig.js in your working directory or repository, based on the template from the DNSControl docs:

var REG_NONE = NewRegistrar("none");
var DNS_BIND = NewDnsProvider("bind");

D("example.com", REG_NONE, DnsProvider(DNS_BIND),
    A("@", "1.2.3.4"),    // "@" means the domain's apex.
);

You’ll also need a creds.json with provider credentials:

{
  "cloudflare": {                              
    "TYPE": "CLOUDFLAREAPI",                    
    "accountid": "your-cloudflare-account-id",  
    "apitoken": "your-cloudflare-api-token"     
  },
  "namecom": {                                  
    "TYPE": "NAMEDOTCOM",                       
    "apikey": "key",                            
    "apiuser": "username"                       
  },
  "none": { "TYPE": "NONE" }   

With these you can verify dnscontrol is working by running dnscontrol preview:

❯ dnscontrol preview
CONCURRENTLY checking for 1 zone(s)
SERIALLY checking for 0 zone(s)
Waiting for concurrent checking(s) to complete...DONE
******************** Domain: example.com
1 correction (bind)
INFO#1: zoneList failed for "bind": directory "zones" does not exist
CONCURRENTLY gathering records of 1 zone(s)
SERIALLY gathering records of 0 zone(s)
Waiting for concurrent gathering(s) to complete...
WARNING: BIND directory "zones" does not exist! (will create)
File does not yet exist: "zones/example.com.zone" (will create)
DONE
******************** Domain: example.com
2 corrections (bind)
#1: + CREATE example.com SOA DEFAULT_NOT_SET. DEFAULT_NOT_SET. 3600 600 604800 1440 ttl=300
+ CREATE example.com A 1.2.3.4 ttl=300
INFO#1: Skipping registrar "none": No nameservers declared for domain "example.com". Add {no_ns:'true'} to force
Done. 3 corrections.
completed with errors

You can also run dnscontrol push which with this sample configuration will create a zones folder with a Bind Zone for example.com

Configuring CloudFlare

The DNSControl documentation has a list of Supported Providers along with configuration guidance. Most support DNS management, some support other registrar functions with is beyond the scope of this guide.

Most of my domains are hosted with Cloudflare, so DNSControl needs permission to manage them.

DNSControl supports Cloudflare, but the official CloudFlare configuration docs are slightly behind the UI. The process, however, is straightforward:

  1. Create an API token (via Profile > API Tokens > Create Token) - click "Get started" on the Custom token template.
  2. Give it a sensible name (eg DNSControl)
  3. Set the following Permissions
    1. Account Permissions:
      1. Workers Scripts – Edit
    2. User Permissions:
      1. User Details – Read
    3. Zone Permissions:
      1. Single Redirect – Edit
      2. Zone – Read
      3. Workers Routes – Edit
      4. SSL and Certificates – Edit
      5. Page Rules – Edit
      6. DNS – Edit
    4. Account Resources: Include → All accounts
    5. Zone Resources: Include → All zones
    6. Client IP Filtering: No filters enabled (fields present but unused)
    7. TTL: Not set (token does not expire unless manually revoked)

The token provided is then used as the apitoken in creds.json.accountid can be found on any page (on the right, scroll down):

The namecom entry isn't going to be used so that can be removed too:

{
  "cloudflare": {
    "TYPE": "CLOUDFLAREAPI",
    "accountid": "1234567890abcdef0123456789abcdef",
    "apitoken": "abcdefghijklmnopqrstuvwxyzABCDEFGH123456"
  }
}

Other providers can be added following the DNSControl documentation.

Importing existing records and seeding the configuration

DNSControl can bootstrap configuration directly from Cloudflare:

dnscontrol get-zones --format=djs --out=draft.js cloudflare - all

draft.js will contain one section per domain, e.g.:

D("mydomain.io", REG_CHANGEME
	, DnsProvider(DSP_CLOUDFLARE)
	, DefaultTTL(1)
	, A("api", "3.8.3.8")
	, A("@", "19.2.6.8")
)

This generated file is not guaranteed to be valid. In my case I hit issues such as:

  • CNAME conflicts with MX or TXT records
  • Apex CNAMEs (not allowed)
  • Records generated with incorrect combinations

You can surface these by running:

dnscontrol preview --config draft.js

A common fix is to replace apex CNAMEs with ALIAS records, e.g.:

ALIAS("@", "some.target.host")

After cleaning up the import, dnscontrol preview should show 0 corrections.

Organising Your Configuration

Once validated, I split draft.js into one file per zone under a domains/ folder:

domains/
  mydomain.co.uk.js
  anotherdomain.com.js
  trendydomain.io.js

My root dnsconfig.js becomes:

require("domains/mydomain.co.uk.js");
require("domains/anotherdomain.com.js");
require("domains/trendydomain.io.js");

Setting sensible defaults

I disable Cloudflare proxying by default:

DEFAULTS(
  CF_PROXY_DEFAULT_OFF // turn proxy off when not specified otherwise
);

Inlining providers

Instead of declaring:

var DSP_CLOUDFLARE = NewDnsProvider("cloudflare");
var REG_CHANGEME = NewRegistrar("none");

…I inline them directly inside each domain:

D("mydomain.io",
  NewRegistrar("none"),
  DnsProvider(NewDnsProvider("cloudflare")),
  DefaultTTL(1),
  A("api", "3.8.3.8"),
  A("@", "19.2.6.8")
);

It’s slightly repetitive, but avoids global variable issues and allows standalone previews:

dnscontrol preview --config domains/mydomain.io.js

So my overall file tree looked like:

❯ tree .
.
├── creds.json
├── dnsconfig.js
├── domains
    ├── mydomain.co.uk.js
    ├── anotherdomain.com.js
    ├── trendydomain.io.js

Helper Scripts

For contributors, I added small helper scripts under scripts/:

preview.sh

#!/bin/bash
cd "$(dirname "$0")/.." || exit
exec dnscontrol preview

apply.sh

#!/bin/bash
cd "$(dirname "$0")/.." || exit
exec dnscontrol push

All they do is ensure the correct working directory before either running the preview or push (apply) command - however they could be extended to check for dnscontrol and/or run it via docker.

I also Git Ignore creds.json but include creds.example.json which includes the expected structure without the sensitive values.

Automation with GitHub Actions

Proper CI ensures DNS changes are validated, previewed and deployed safely.

Syntax Checking

Runs on every pull request:

name: Check

on: pull_request

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: DNSControl check
        uses: koenrh/dnscontrol-action@v3
        with:
          args: check

Pull Request Previews

Annotates the PR with the expected changes:

name: Preview

on: pull_request

jobs:
  preview:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: DNSControl preview
        uses: koenrh/dnscontrol-action@v3
        id: dnscontrol_preview
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        with:
          args: preview
          creds_file: 'creds.ci.json'
      - name: Preview pull request comment
        uses: unsplash/comment-on-pr@v1.3.0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          msg: |
            ```
            ${{ steps.dnscontrol_preview.outputs.preview_comment }}
            ```
          check_for_duplicate_msg: true          

Applying changes on Merge with push

name: Push

on:
  push:
    branches:
      - main

jobs:
  push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - name: DNSControl push
        uses: koenrh/dnscontrol-action@v3
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        with:
          args: push
          creds_file: 'creds.ci.json'

Authentication

While the first action runs in isolation, the latter two talk to the provider API and require credentials.

The DNSControl action is instructed to use a creds.ci.json file which is committed with the following content:

{
  "cloudflare": {
    "TYPE": "CLOUDFLAREAPI",
    "accountid": "$CLOUDFLARE_ACCOUNT_ID",
    "apitoken": "$CLOUDFLARE_API_TOKEN"
  }
}

The action will need secrets adding for these values. For this I used an Account API Token (Manage account > Account API Tokens) with the following permissions (similar to the User API token):

Permissions

  • Account → Workers Scripts: Edit
  • Zone → Single Redirect: Edit
  • Zone → Zone Settings: Read
  • Zone → Zone: Edit
  • Zone → Workers Routes: Edit
  • Zone → SSL and Certificates: Edit
  • Zone → DNS: Edit

Zone Resources

  • Include: All zones from the account
  • Account selected: My Account

Client IP Address Filtering

  • No IP filter values selected (operators and values empty)

TTL

  • No start/end date selected (token does not have a TTL set)

Conclusion

Putting DNS under version control with DNSControl turns what is usually a fiddly, error-prone chore into something structured, reviewable and automated. Once everything is set up with credentials, domain files, helper scripts and GitHub Actions, DNS updates become a straightforward part of your normal Git workflow. You propose a change, preview it, review it, merge it, and automation takes care of the rest.

There is a bit of upfront effort involved in importing existing records, cleaning up zones and settling on a layout that works for you, but the payoff is considerable. You avoid endless clicking through provider dashboards, prevent unknown drift and always know who changed what. Everything is in one place, everything is reproducible and every modification has a clear audit trail.

If you manage more than a small handful of domains or collaborate with others, DNSControl soon becomes one of those tools you wish you had adopted earlier. It is predictable, scriptable and extensible, and it brings some much-needed sanity back to DNS management.