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?
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@latestIf 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:
- Create an API token (via Profile > API Tokens > Create Token) - click "Get started" on the Custom token template.
- Give it a sensible name (eg DNSControl)
- Set the following Permissions
- Account Permissions:
- Workers Scripts – Edit
- User Permissions:
- User Details – Read
- Zone Permissions:
- Single Redirect – Edit
- Zone – Read
- Workers Routes – Edit
- SSL and Certificates – Edit
- Page Rules – Edit
- DNS – Edit
- Account Resources: Include → All accounts
- Zone Resources: Include → All zones
- Client IP Filtering: No filters enabled (fields present but unused)
- TTL: Not set (token does not expire unless manually revoked)
- Account Permissions:
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 - alldraft.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.jsA 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.jsHelper Scripts
For contributors, I added small helper scripts under scripts/:
preview.sh
#!/bin/bash
cd "$(dirname "$0")/.." || exit
exec dnscontrol previewapply.sh
#!/bin/bash
cd "$(dirname "$0")/.." || exit
exec dnscontrol pushAll 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: checkPull 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.