DNS Automation and Infrastructure as Code

Manage DNS with Terraform, OctoDNS, DNSControl, and GitOps workflows — version-controlled, reviewable, and repeatable.

Editing DNS records through a web UI is fine for a personal blog. But when you’re managing dozens of domains across multiple providers, every manual click is a potential outage. One fat-fingered IP address, one forgotten trailing dot, one accidental deletion — and you’re scrambling.

Infrastructure as Code (IaC) solves this. DNS changes become pull requests. Reviews happen before deployment. Rollbacks are a git revert away. Let’s look at the tools that make this possible.

Why Automate DNS?

Before diving into tools, consider what manual DNS management actually costs:

  • No audit trail — Who changed that A record three months ago? Why?
  • No review process — Changes go live immediately, no second pair of eyes
  • No rollback — Reverting a bad change means remembering what it was before
  • No testing — You find out a change is broken when users report it
  • Provider lock-in — Your DNS knowledge is in a web UI you can’t export

IaC DNS addresses all of these. Your DNS records live in version-controlled files (a modern evolution of zone files), changes are reviewed before applying, and your entire DNS configuration is portable between providers.

Terraform: The Universal Approach

Terraform by HashiCorp is the most widely adopted IaC tool, and it supports DNS management through providers for Cloudflare, AWS Route 53, Google Cloud DNS, Azure DNS, and many others.

Basic Cloudflare DNS with Terraform

# providers.tf
terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.0"
    }
  }
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}

# variables.tf
variable "cloudflare_api_token" {
  type      = string
  sensitive = true
}

variable "zone_id" {
  type    = string
  default = "abc123def456"
}
# dns.tf
resource "cloudflare_record" "root_a" {
  zone_id = var.zone_id
  name    = "example.com"
  type    = "A"
  content = "203.0.113.50"
  ttl     = 3600
  proxied = true
}

resource "cloudflare_record" "www_cname" {
  zone_id = var.zone_id
  name    = "www"
  type    = "CNAME"
  content = "example.com"
  ttl     = 3600
  proxied = true
}

resource "cloudflare_record" "mx_primary" {
  zone_id  = var.zone_id
  name     = "example.com"
  type     = "MX"
  content  = "aspmx.l.google.com"
  priority = 1
  ttl      = 3600
}

resource "cloudflare_record" "spf" {
  zone_id = var.zone_id
  name    = "example.com"
  type    = "TXT"
  content = "v=spf1 include:_spf.google.com -all"
  ttl     = 3600
}

resource "cloudflare_record" "dmarc" {
  zone_id = var.zone_id
  name    = "_dmarc"
  type    = "TXT"
  content = "v=DMARC1; p=reject; rua=mailto:dmarc@example.com"
  ttl     = 3600
}

Terraform Workflow

# Preview changes (dry run)
$ terraform plan
Terraform will perform the following actions:

  # cloudflare_record.root_a will be created
  + resource "cloudflare_record" "root_a" {
      + name    = "example.com"
      + type    = "A"
      + content = "203.0.113.50"
      + ttl     = 3600
    }

Plan: 5 to add, 0 to change, 0 to destroy.

# Apply changes
$ terraform apply
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.

# See current state
$ terraform state list
cloudflare_record.root_a
cloudflare_record.www_cname
cloudflare_record.mx_primary
cloudflare_record.spf
cloudflare_record.dmarc

AWS Route 53 with Terraform

resource "aws_route53_zone" "main" {
  name = "example.com"
}

resource "aws_route53_record" "root" {
  zone_id = aws_route53_zone.main.zone_id
  name    = "example.com"
  type    = "A"
  ttl     = 300

  records = ["203.0.113.50"]
}

# Route 53 supports ALIAS records natively
resource "aws_route53_record" "root_alias" {
  zone_id = aws_route53_zone.main.zone_id
  name    = "example.com"
  type    = "A"

  alias {
    name                   = "d123abc.cloudfront.net"
    zone_id                = "Z2FDTNDATAQYW2"  # CloudFront's hosted zone ID
    evaluate_target_health = false
  }
}

Importing Existing Records

Already have DNS records configured manually? Import them into Terraform state:

# Import a Cloudflare record
$ terraform import cloudflare_record.root_a abc123def456/xyz789

# Import a Route 53 record
$ terraform import aws_route53_record.root Z1234567890_example.com_A

# Then run plan to see if your config matches reality
$ terraform plan
# No changes = your code matches what's deployed

OctoDNS: DNS as YAML

OctoDNS, created by GitHub, takes a different approach. Instead of a general-purpose IaC tool, it’s purpose-built for DNS. Records are defined in YAML, and OctoDNS syncs them to your providers.

Configuration

# config.yaml
providers:
  config:
    class: octodns_bind.ZoneFileSource
    directory: ./zones

  cloudflare:
    class: octodns_cloudflare.CloudflareProvider
    token: env/CLOUDFLARE_TOKEN

  route53:
    class: octodns_route53.Route53Provider
    access_key_id: env/AWS_ACCESS_KEY_ID
    secret_access_key: env/AWS_SECRET_ACCESS_KEY

zones:
  example.com.:
    sources:
      - config
    targets:
      - cloudflare
      - route53  # Multi-provider! Same records to both
# zones/example.com.yaml
---
'':
  - type: A
    value: 203.0.113.50
    ttl: 3600
  - type: MX
    values:
      - priority: 1
        value: aspmx.l.google.com.
      - priority: 5
        value: alt1.aspmx.l.google.com.
  - type: TXT
    value: "v=spf1 include:_spf.google.com -all"

www:
  type: CNAME
  value: example.com.
  ttl: 3600

api:
  type: A
  value: 203.0.113.60
  ttl: 300

_dmarc:
  type: TXT
  value: "v=DMARC1; p=reject; rua=mailto:dmarc@example.com"

OctoDNS Workflow

# Dry run — shows what would change
$ octodns-sync --config-file=config.yaml
...
* example.com.
*   Create <ARecord A 3600, example.com., ['203.0.113.50']>
*   Create <MxRecord MX 3600, example.com., [''1 aspmx.l.google.com.'']>
*   Summary: Creates=5, Updates=0, Deletes=0, Existing=0
...

# Apply changes
$ octodns-sync --config-file=config.yaml --doit

OctoDNS’s superpower is multi-provider sync. Define records once, deploy to Cloudflare and Route 53 simultaneously for redundancy.

DNSControl: The StackOverflow Approach

DNSControl was created by StackOverflow and uses a JavaScript-based DSL for DNS configuration.

// dnsconfig.js
var REG_NAMECHEAP = NewRegistrar("namecheap");
var DSP_CLOUDFLARE = NewDnsProvider("cloudflare");

D("example.com", REG_NAMECHEAP, DnsProvider(DSP_CLOUDFLARE),
  A("@", "203.0.113.50", TTL(3600)),
  AAAA("@", "2001:db8::1", TTL(3600)),
  CNAME("www", "example.com.", TTL(3600)),

  // Email
  MX("@", 1, "aspmx.l.google.com."),
  MX("@", 5, "alt1.aspmx.l.google.com."),
  TXT("@", "v=spf1 include:_spf.google.com -all"),
  TXT("_dmarc", "v=DMARC1; p=reject; rua=mailto:dmarc@example.com"),

  // API subdomain
  A("api", "203.0.113.60", TTL(300)),

  // Disable records you want to explicitly remove
  // DNSControl will delete anything not in this file
END);
# Preview changes
$ dnscontrol preview
******************** Domain: example.com
----- Getting nameservers from: cloudflare
----- DNS Provider: cloudflare... 5 corrections
#1: CREATE A example.com 203.0.113.50 ttl=3600
#2: CREATE AAAA example.com 2001:db8::1 ttl=3600
...

# Apply
$ dnscontrol push

DNSControl’s standout feature is that it manages the complete zone — any records that exist in your provider but not in your config file will be deleted. This “desired state” model prevents configuration drift.

GitOps Workflow for DNS

Regardless of which tool you choose, the workflow should look like this:

Developer → Feature Branch → Pull Request → Review → Merge → Apply
                                    │
                                    ├── Automated dry-run (CI)
                                    ├── Peer review (human)
                                    └── Approval gate

GitHub Actions Example

# .github/workflows/dns.yml
name: DNS Management

on:
  pull_request:
    paths: ['dns/**']
  push:
    branches: [main]
    paths: ['dns/**']

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Terraform Plan
        run: |
          cd dns/
          terraform init
          terraform plan -no-color
        env:
          CLOUDFLARE_API_TOKEN: $

  apply:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Terraform Apply
        run: |
          cd dns/
          terraform init
          terraform apply -auto-approve
        env:
          CLOUDFLARE_API_TOKEN: $

Every DNS change is now a PR. The CI pipeline shows what will change. A teammate reviews and approves. Merging to main triggers the actual update. If something goes wrong, git revert + merge = instant rollback.

Testing DNS Changes Before Applying

Local Validation

# Terraform: validate syntax
$ terraform validate
Success! The configuration is valid.

# DNSControl: check for errors
$ dnscontrol check
No errors.

# Named: validate zone files
$ named-checkzone example.com zones/example.com.zone
zone example.com/IN: loaded serial 2024030101
OK

Staging Environments

Some teams maintain a separate “staging” domain (e.g., example-staging.com) with identical structure. Test DNS changes there first, then apply to production.

Comparing Desired vs Actual State

# OctoDNS dry run compares YAML to live DNS
$ octodns-sync --config-file=config.yaml 2>&1 | grep -E '(Create|Update|Delete)'

# Terraform plan shows the diff
$ terraform plan | grep -E '(created|changed|destroyed)'

Key Takeaways

  • DNS-as-code gives you audit trails, reviews, rollbacks, and portability
  • Terraform is the most versatile choice — works for DNS plus everything else in your infrastructure
  • OctoDNS excels at multi-provider sync and YAML simplicity
  • DNSControl offers complete zone management with drift prevention
  • GitOps workflows (PR → review → merge → apply) should be mandatory for production DNS
  • Always dry-run before applying — every tool supports it
  • Test changes in staging or with dry-run modes before touching production