DNS as Code with DNSControl
I manage the DNS zones for three domains: inf3.ch, inf3.xyz and schwery.me. They hold the records for my servers, my websites, a few personal services and of course the email. I don’t change them often, maybe a few times a year, and that is exactly the problem: every time, I had forgotten how a record was configured and why. Since 2017, these zones are in a Git repository and are deployed to Amazon Route53 by a GitLab pipeline, so that the history and the reason of each change stay next to the configuration.
In this article I will present the tool I use today, DNSControl, how the configuration is structured, how the email records are declared and how the changes are deployed on merge.
The writing of this article was triggered by the migration from GitLab pipelines to Woodpecker pipelines and not by any specific change to the DNS projects. I’m very happy with the current state of things for now, maybe testing new DNS servers but nothing major.
From octoDNS to DNSControl
The first version of the repository, in August 2017, used
octoDNS with one YAML file per zone
and a small shell script to run octodns-sync. It worked, but the YAML
files were long and I had to repeat the same records (server addresses,
mail, CAA) in each zone.
In July 2020 I migrated everything to DNSControl. The configuration is a
single dnsconfig.js file written in a JavaScript-like DSL, which means
that I can use variables to share records between the domains. Since then,
I have been able to easily try new DNS servers, like Gandi LiveDNS before
deciding the features were greener elsewhere and coming back to Route53.
With DNSControl, this kind of move is mostly a change of provider in the
configuration.
The basics
A DNSControl configuration declares providers, then one D() block per
domain. Here is the beginning of my dnsconfig.js:
DEFAULTS(
NAMESERVER_TTL('48h'),
DefaultTTL('300')
);
var r53_main = NewDnsProvider("r53_main");
var reg_none = NewRegistrar("ThirdParty");
The DEFAULTS apply to all the domains. I lowered the default TTL to 5
minutes at the beginning of 2022.
The registrar is set to ThirdParty (the NONE registrar) because the
domains are registered elsewhere and I only want DNSControl to manage the
records, not the delegation.
The provider names refer to entries in a creds.json file that contains
the provider type and the credentials. This file is in the .gitignore and
never committed. For Route53 it has this shape:
{
"r53_main": {
"TYPE": "ROUTE53",
"KeyId": "<your-access-key-id>",
"SecretKey": "<your-secret-access-key>"
}
}
With this in place, two commands are enough. dnscontrol preview reads
the configuration, fetches the current zones from Route53 and prints the
list of changes that would be made. dnscontrol push does the same and
applies them. Because DNSControl owns the whole zone, a record that exists
on Route53 but not in the file will be deleted, so I always read the
preview before pushing.
Structuring the records
The useful part of having a real language is that groups of records can be put in variables and reused. All three domains share the CAA records that limit which certificate authorities can issue certificates:
var ns_caa = [
CAA('@', 'issue', 'amazontrust.com', CAA_CRITICAL, TTL(3600)),
CAA('@', 'issue', 'letsencrypt.org', CAA_CRITICAL, TTL(3600)),
CAA('@', 'issue', 'buypass.com', CAA_CRITICAL, TTL(3600)),
];
Each server has its own small list with its IPv4 and IPv6 addresses, and
all the servers are grouped in a srvs list:
var srv_arven = [
A('arven', '203.0.113.10'),
AAAA('arven', '2001:db8::1'),
]
var srvs = [
srv_arven,
srv_belmor,
srv_calyx,
srv_dorvan,
srv_eskel
]
A domain is then mostly a composition of these lists, plus the records that are specific to it:
D("inf3.ch", reg_none, DnsProvider(r53_main),
web_inf3,
ns_caa,
srvs,
mail_base,
// Personal services
CNAME('rss', 'arven'),
CNAME('sync', 'arven'),
// Websites
CNAME('thomas', 'example-blog.netlify.app.'),
);
When I add or remove a server, I change one list and every domain gets the
new names. Services point to a server with a CNAME, so moving a service
like the RSS reader from one machine to another is a one line change.
A pinned toolchain with nix-shell
For a long time I simply had a DNSControl binary downloaded next to the
configuration. To avoid that, I added a shell.nix file:
{ pkgs ? import (fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/nixos-26.05.tar.gz";
}) {} }:
pkgs.mkShell {
packages = [ pkgs.dnscontrol ];
}
The nixpkgs reference follows the stable channel, so nix-shell gives the
DNSControl version of the current NixOS release on every machine, and I
bump the channel in the URL once a new release comes out. I do not need an
exact version pin here; staying within a stable release is enough.
Deployment on merge
The pipeline has not changed much since I started the repository.
It has two jobs using the official DNSControl container image.
The creds.json is stored as a file variable in the GitLab CI/CD settings
and copied in place before running the command:
dnscontrol-test:
image: stackexchange/dnscontrol:v4.17.0
stage: test
script:
- cp $CREDS_FILE creds.json
- dnscontrol preview
dnscontrol-deploy:
image: stackexchange/dnscontrol:v4.17.0
stage: deploy
script:
- cp $CREDS_FILE creds.json
- dnscontrol push
rules:
- if: '$CI_COMMIT_BRANCH == "master"'
Every branch runs preview, so the merge request shows in the job logs
exactly which records will be created, changed or deleted. Once the branch
is merged on master, the push job applies the changes to Route53.
Conclusion
Having the DNS zones as code has been worth it for me mainly because of the
history: git log tells me when and why a record was added, and the
preview in the pipeline catches a lot of mistakes before they reach the
internet.
