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