Configuration of DNS zones is a necessary part of most (web) software projects. Whether during the initial setup or over time, domains need to be registered, and DNS records need to be set up or adjusted. While this can often be done manually through the web interface of the corresponding provider, it’s error-prone, hard to track, and not easy to automate.

If you use Infrastructure-as-Code (IaC) tools like Terraform for provisioning your cloud infrastructure, you can also manage DNS configuration through it. However, this approach often restricts you to the major hyperscale cloud providers for DNS services.

DNSControl provides a simpler and less configuration-intensive way to define DNS configuration for dozens of large and small providers using JavaScript code. This allows you to set up and manage your DNS environment in an automated and reproducible manner. The configuration is versioned in a Git repository and can undergo code reviews, seamlessly integrating the process into the DevOps toolchain.

Installation

You can install DNSControl through a package manager like brew or by downloading the binary from the project’s GitHub page. The program, written in Go, is available for Windows, Linux, and macOS. Additionally, there’s a pre-built Docker image.

Configuration

DNSControl understands DNS registrars and DNS service providers (DSP). A registrar allows you to register new domains and specify the responsible nameservers. A DSP manages DNS zones and serves DNS requests. Often, a registrar also functions as a DSP. However, the configuration still needs to be done for both entities.

Providing credentials

In the creds.json file, you store the credentials for the providers you’re using. Most providers require access credentials in the form of an API key or something similar. The exact syntax can be found in the documentation of each provider. Some providers can only be used as DSPs, while others can only be used as registrars. To create a local zone file for BIND configuration, you can use the BIND type. If you’re not using a registrar, you can use the NONE type.

To avoid storing credentials in plain text in the configuration file, you can use environment variables. These variables are referenced in the configuration file using the $NAME syntax.

The following example configures BIND, a dummy entry, and Hetzner as a provider.

{
    "local": {
        "TYPE": "BIND"
    },
    "none": {
        "TYPE": "NONE"
    },
    "hetzner": {
        "TYPE": "HETZNER",
        "api_key": "$DNSCONTROL_HETZNER_API_KEY"
    }
}

You can check if your configuration is correct using check-creds. The get-zones command lets you retrieve the DNS zones that are already present in your DNS provider. Formats like tsv (tab-separated values) and the BIND format zone are available. nameonly displays only the zone names.

# Check credentials
dnscontrol check-creds hetzner

# Get all zones from provider
dnscontrol get-zones --format=nameonly hetzner - all

Configuring DNS Zones

The configuration of DNS zones is done using JavaScript code, typically in the dnsconfig.js file. This approach offers great flexibility for recurring settings such as DNS or mail servers.

The NewRegistrar() and NewDnsProvider() functions are used to read the configured providers from the creds.json file. Entries are identified by their names.

The D() function is used to define a DNS zone. It takes parameters such as the name of the zone, the registrar, the DSP, and a list of DNS entries. For different types of entries, there are corresponding functions known as Domain Modifiers available, such as A() and AAAA() for A and AAAA records, respectively.

var REG = NewRegistrar("none");;
var DNS = NewDnsProvider("hetzner");;

var HETZNER_NAMESERVER_RECORDS = [
    NAMESERVER("helium.ns.hetzner.de."),
    NAMESERVER("hydrogen.ns.hetzner.com."),
    NAMESERVER("oxygen.ns.hetzner.com.")
];

D("production-ready.de", REG, DnsProvider(DNS),
    A("@", "116.203.23.216"),
    A("www", "116.203.23.216"),
    AAAA("@", "2a01:4f8:c0c:bf6a::1"),
    MX("@", 10, "mail.example.com."),
    HETZNER_NAMESERVER_RECORDS
);

For constructing more complex records, there are builder functions available. The resulting object can be easily included in the list of DNS entries.

var CAA = CAA_BUILDER({
    label: "@",
    issue: ["letsencrypt.org"],
    iodef: "mailto:administrator@example.com",
    iodef_critical: true,
    issuewild: "none"
});

var DMARC = DMARC_BUILDER({
    policy: "none",
    subdomainPolicy: "none",
    rua: ["mailto:postmaster@example.de"],
    ruf: ["mailto:postmaster@example.de"],
    reportInterval: "7d"
});

D("example.com", REG, DnsProvider(DNS),
    A("@", "116.203.23.216"),
    ...
    CAA,
    DMARC
);

If you have multiple domains to manage, it’s a good idea to split the configuration into multiple files. These files can then be imported into the main dnsconfig.js file.

require("domains/production-ready.de.js");

Importing Existing DNS Zones

If you want to migrate existing DNS zones into DNSControl, you can save some typing by using get-zones with the output format set to js. Then, you can write the content to a file using --out [file].js. The generated code can serve as a starting point for your own configuration.

> dnscontrol get-zones --format js --out import.js hetzner - production-ready.de

var DSP_HETZNER = NewDnsProvider("hetzner");
var REG_CHANGEME = NewRegistrar("none");
D("production-ready.de", REG_CHANGEME,
        DnsProvider(DSP_HETZNER),
        DefaultTTL(3600),
        //NAMESERVER('oxygen.ns.hetzner.com.'),
        //NAMESERVER('hydrogen.ns.hetzner.com.'),
        //NAMESERVER('helium.ns.hetzner.de.'),
        A('@', '116.203.23.216'),
        A('www', '116.203.23.216'),
        ...
)

Type-Checking

To enable type-checking and code completion similar like TypeScript in your JavaScript files, DNSControl offers a useful feature: generating a TypeScript declaration file.

dnscontrol write-types

At the beginning of the dnsconfig.js file, you reference the generated TypeScript Declaration File.

// @ts-check
/// <reference path="types-dnscontrol.d.ts" />

...

Checking and Applying Changes

For validating the configuration, DNSControl provides the check command. This command checks the JavaScript files for errors but doesn’t interact with any DNS provider. The preview command compares the current configuration with the state of DNS zones at the provider and displays potential changes. In the following example, a CNAME record is added, and an A record is modified.

# Check and validate dnsconfig.js
> dnscontrol check
No errors.

# Preview changes
> dnscontrol preview
******************** Domain: production-ready.de
2 corrections (hetzner)
#1: Batch creation of records:
   + CREATE CNAME www2.production-ready.de www.production-ready.de. ttl=3600
#2: Batch modification of records:
   ± MODIFY A production-ready.de: (116.203.23.216 ttl=3600) -> (192.168.0.1 ttl=3600)
Done. 2 corrections.

If the changes align with the expected results, you can apply them using the push command.

> dnscontrol push

2 corrections (hetzner)
#1: Batch creation of records:
   + CREATE CNAME www2.production-ready.de www.production-ready.de. ttl=3600
SUCCESS!
#2: Batch modification of records:
   ± MODIFY A production-ready.de: (116.203.23.216 ttl=3600) -> (192.168.0.1 ttl=3600)
SUCCESS!
Done. 2 corrections.

With these tools and approaches, you are capable of effectively and transparently managing an entire DNS landscape using an Infrastructure-as-Code approach.

Azure DevOps Pipeline

To avoid the need to execute DNS configuration changes locally, creating a build pipeline is a convenient option. This pipeline can also be used to validate pull requests containing DNS changes. The provided code is tailored for Azure DevOps, but similar approaches can be adapted for other CI/CD systems.

If DNSControl is not installed on your build agent, such as when using Microsoft’s Hosted Agents or your own Docker-based agents, you can perform this installation through a simple CmdLine task. Create a folder, download the file, extract it, and make the binary executable.

- task: CmdLine@2
  displayName: "Install DNSControl"
  inputs:
      script: |
        mkdir -p $(Agent.TempDirectory)/bin
        cd $(Agent.TempDirectory)/bin
        wget -q https://github.com/StackExchange/dnscontrol/releases/download/v4.1.1/dnscontrol_4.1.1_linux_amd64.tar.gz -O dnscontrol.tar.gz
        tar -xf dnscontrol.tar.gz dnscontrol
        chmod +x dnscontrol

Subsequently, you can validate the configuration and display the changes. The API key for the provider can be stored as a secret variable in the pipeline configuration or retrieved from an Azure KeyVault. When using a variable, explicit mapping to an environment variable is crucial; otherwise, the API key won’t be accessible in the script.

- task: CmdLine@2
  displayName: "Run DNSControl check"
  inputs: 
    script: $(Agent.TempDirectory)/bin/dnscontrol check

- task: CmdLine@2
  displayName: "Run DNSControl preview"
  inputs: 
    script: $(Agent.TempDirectory)/bin/dnscontrol preview
  env:
    DNSCONTROL_HETZNER_API_KEY: $(DNSCONTROL_HETZNER_API_KEY)

If the changes align with the desired outcome, you can apply them in a deployment step.

jobs:
  - deployment: 
    displayName: "Apply DNS Update"
    environment: HETZNER_DNS_PROD
    strategy:
      runOnce:
        deploy:
          steps:
            - task: CmdLine@2
              displayName: "Run DNSControl push"
              inputs: 
                script: $(Agent.TempDirectory)/bin/dnscontrol push
              env:
                DNSCONTROL_HETZNER_API_KEY: $(DNSCONTROL_HETZNER_API_KEY)
DNSControl in Azure DevOps Pipeline