
How to manage DNS as code with Terraform?
To manage DNS as code with Terraform, you configure the Terraform provider for your DNS host (for example cloudflare/cloudflare or hashicorp/aws for Route 53), describe each zone and record as a resource in .tf files, store those files in Git, and apply changes with terraform plan and terraform apply. Existing records are brought under management with import blocks, state is kept in a shared remote backend, and changes go through pull requests so every DNS edit is reviewed, versioned and reversible.
Clicking around a DNS dashboard works for a handful of records, but it doesn't scale and it leaves no history of who changed what or why. In this guide you'll set up a DNS project in Terraform for Cloudflare (provider v5) and Amazon Route 53, define records cleanly with variables and for_each, import a zone that already exists, protect critical records, run plans automatically in CI, and avoid the most common pitfalls.
Why Manage DNS as Code?
Treating DNS records like application code brings several practical benefits:
- Review before change: Every edit appears as a diff in a pull request, so a colleague can spot a typo in an
MXrecord before it breaks email. - History and rollback: Git shows who changed each record, when and why, and reverting a commit undoes a bad change.
- Consistency: The same patterns apply across dozens of zones, and records can be generated from data rather than typed by hand.
- Drift detection:
terraform planshows when someone has edited a record directly in the dashboard. - Disaster recovery: If an account is compromised or a zone is deleted, you can recreate it from code.
- Multi-provider setups: The same data can drive records at two providers for redundancy.
Project Layout
A simple layout for one or more zones looks like this:
dns/
├── versions.tf # Terraform and provider versions
├── backend.tf # Remote state configuration
├── providers.tf # Provider authentication
├── variables.tf # Input variables
├── example-com.tf # Records for example.com
└── example-net.tf # Records for example.net
Larger organisations often split each zone, or each team's zones, into separate root modules with separate state, so a mistake in one can't affect the others.
Setting Up the Providers
This guide targets Terraform 1.10 or later (OpenTofu works the same way for everything shown here), the Cloudflare provider v5, and the AWS provider v6.
# versions.tf
terraform {
required_version = ">= 1.10"
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5.0"
}
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
Pin provider versions. DNS providers sometimes make breaking changes between major versions, and the Cloudflare move from v4 to v5 is a good example: the record resource was renamed from cloudflare_record to cloudflare_dns_record, and the record value attribute is now content.
Authentication
Never put credentials in .tf files. The Cloudflare provider reads an API token from the CLOUDFLARE_API_TOKEN environment variable, and the AWS provider uses the standard AWS credential chain:
# providers.tf
provider "cloudflare" {}
provider "aws" {
region = "us-east-1"
}
export CLOUDFLARE_API_TOKEN="your-scoped-token"
export AWS_PROFILE="dns-admin"
Create a Cloudflare API token with only the Zone: DNS: Edit permission (plus Zone: Zone: Read), limited to the zones Terraform manages. For AWS, grant an IAM role permissions for route53:ChangeResourceRecordSets, route53:GetChange and the relevant List and Get actions on specific hosted zones.
Remote State
Terraform stores what it manages in a state file. For a team, keep it in a shared backend with locking so two people can't apply at once. An S3 backend with native lock files (available from Terraform 1.10) looks like this:
# backend.tf
terraform {
backend "s3" {
bucket = "example-terraform-state"
key = "dns/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
HCP Terraform, Google Cloud Storage and Azure Blob Storage backends work just as well.
Managing Cloudflare Records (Provider v5)
Start with the zone ID as a variable. You can find it on the zone's overview page in the Cloudflare dashboard.
# variables.tf
variable "cloudflare_zone_id" {
description = "Cloudflare zone ID for example.com"
type = string
}
Individual Records
# example-com.tf
resource "cloudflare_dns_record" "apex" {
zone_id = var.cloudflare_zone_id
name = "example.com"
type = "A"
content = "203.0.113.10"
ttl = 1 # 1 means automatic, required for proxied records
proxied = true
comment = "Main website"
}
resource "cloudflare_dns_record" "www" {
zone_id = var.cloudflare_zone_id
name = "www.example.com"
type = "CNAME"
content = "example.com"
ttl = 1
proxied = true
}
resource "cloudflare_dns_record" "mx_primary" {
zone_id = var.cloudflare_zone_id
name = "example.com"
type = "MX"
content = "mx1.mail.example.net"
priority = 10
ttl = 3600
}
resource "cloudflare_dns_record" "spf" {
zone_id = var.cloudflare_zone_id
name = "example.com"
type = "TXT"
content = "\"v=spf1 include:_spf.mail.example.net -all\""
ttl = 3600
}
A few v5 details to note:
ttlis required in v5. Use1for automatic.- Use fully qualified names, such as
www.example.com. The API returns full names, and using them avoids perpetual diffs. - Wrap TXT values in quotes, as shown. Cloudflare recommends quoted TXT content, and keeping it consistent avoids noisy plans.
Generating Records From Data
Writing a resource block per record gets repetitive. A map and for_each keep things tidy:
locals {
cloudflare_records = {
"api-a" = { name = "api.example.com", type = "A", content = "203.0.113.20", proxied = true }
"api-aaaa" = { name = "api.example.com", type = "AAAA", content = "2001:db8::20", proxied = true }
"status" = { name = "status.example.com", type = "CNAME", content = "status.example.net", proxied = false }
"caa" = { name = "example.com", type = "CAA", content = "0 issue \"letsencrypt.org\"", proxied = false }
}
}
resource "cloudflare_dns_record" "managed" {
for_each = local.cloudflare_records
zone_id = var.cloudflare_zone_id
name = each.value.name
type = each.value.type
content = each.value.content
proxied = each.value.proxied
ttl = each.value.proxied ? 1 : 300
}
Use stable keys in the map, such as api-a, rather than list indexes. With count, removing one item shifts every index after it, and Terraform would try to delete and recreate unrelated records.
Managing Route 53 Records
Route 53 uses aws_route53_zone for hosted zones and aws_route53_record for records. Unlike Cloudflare, one aws_route53_record holds the entire record set, so multiple values go into the records list.
resource "aws_route53_zone" "example_net" {
name = "example.net"
comment = "Managed by Terraform"
}
resource "aws_route53_record" "apex_a" {
zone_id = aws_route53_zone.example_net.zone_id
name = "example.net"
type = "A"
ttl = 300
records = ["198.51.100.10", "198.51.100.11"]
}
resource "aws_route53_record" "mx" {
zone_id = aws_route53_zone.example_net.zone_id
name = "example.net"
type = "MX"
ttl = 3600
records = [
"10 mx1.mail.example.net",
"20 mx2.mail.example.net",
]
}
resource "aws_route53_record" "txt" {
zone_id = aws_route53_zone.example_net.zone_id
name = "example.net"
type = "TXT"
ttl = 3600
records = [
"v=spf1 include:_spf.mail.example.net -all",
"google-site-verification=abc123example",
]
}
output "example_net_nameservers" {
value = aws_route53_zone.example_net.name_servers
}
Notice that all TXT values for the same name must be in a single aws_route53_record, because Route 53 treats them as one record set. Two separate resources for the same name and type will conflict.
Alias Records
To point the apex at an AWS load balancer, use an alias block instead of ttl and records:
resource "aws_route53_record" "www" {
zone_id = aws_route53_zone.example_net.zone_id
name = "www.example.net"
type = "A"
alias {
name = aws_lb.web.dns_name
zone_id = aws_lb.web.zone_id
evaluate_target_health = true
}
}
This assumes an aws_lb.web resource defined elsewhere in your configuration.
Importing Existing DNS Records
Most teams start with records that already exist. Terraform's import blocks (Terraform 1.5 and later) bring them under management without recreating them.
Cloudflare
For the v5 provider, the import ID is the zone ID and the record ID separated by a slash:
import {
to = cloudflare_dns_record.www
id = "023e105f4ecef8ad9ca31a8372d0c353/372e67954025e0ba6aaa6d586b9e0b59"
}
You can find record IDs through the API:
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?name=www.example.com" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[] | "\(.id) \(.type) \(.name)"'
372e67954025e0ba6aaa6d586b9e0b59 CNAME www.example.com
For whole zones, Cloudflare's cf-terraforming tool generates both the resource blocks and the import blocks for you:
cf-terraforming generate --resource-type "cloudflare_dns_record" --zone "$ZONE_ID" > imported.tf
cf-terraforming import --resource-type "cloudflare_dns_record" --zone "$ZONE_ID" --modern-import-block >> imports.tf
Route 53
Route 53 record import IDs combine the zone ID, name and type, with an optional set identifier for routing policies:
import {
to = aws_route53_record.apex_a
id = "Z0123456789ABCDEFGHIJ_example.net_A"
}
Generating Configuration
If you write import blocks but not the matching resources, Terraform can generate starter configuration:
terraform plan -generate-config-out=generated.tf
Review the generated file carefully, tidy it up, then run terraform plan again. The goal is a plan that shows no changes, which proves your code matches what's live.
The Day-to-Day Workflow
Once your zone is in code, every change follows the same steps:
terraform fmt
terraform validate
terraform plan -out=dns.tfplan
terraform apply dns.tfplan
A plan for a single new record looks like this:
Terraform will perform the following actions:
# cloudflare_dns_record.managed["docs"] will be created
+ resource "cloudflare_dns_record" "managed" {
+ content = "docs.example.net"
+ name = "docs.example.com"
+ proxied = false
+ ttl = 300
+ type = "CNAME"
+ zone_id = "023e105f4ecef8ad9ca31a8372d0c353"
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
Always read the summary line. A plan that says 1 to destroy when you only meant to add a record is a warning to stop.
Running Plans in CI
Running terraform plan on every pull request lets reviewers see exactly what will change. Here's a minimal GitHub Actions workflow:
# .github/workflows/dns.yml
name: DNS
on:
pull_request:
paths: ["dns/**"]
push:
branches: [main]
paths: ["dns/**"]
permissions:
contents: read
id-token: write
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: dns
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
TF_VAR_cloudflare_zone_id: ${{ vars.CLOUDFLARE_ZONE_ID }}
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/dns-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- run: terraform fmt -check
- run: terraform validate
- run: terraform plan -input=false -out=dns.tfplan
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -input=false dns.tfplan
Protect the main branch so changes need an approving review, and use GitHub environments with required reviewers if you want a manual approval step before apply.
Protecting Critical Records
Some records are too important to delete by accident. The prevent_destroy lifecycle setting makes Terraform refuse any plan that would destroy them:
resource "aws_route53_zone" "example_net" {
name = "example.net"
lifecycle {
prevent_destroy = true
}
}
Apply it to hosted zones and to records such as MX and the apex, where a mistaken delete would cause an outage. You can also add a CI check that fails if a plan contains deletions:
terraform show -json dns.tfplan \
| jq -e '[.resource_changes[] | select(.change.actions | index("delete"))] | length == 0'
Handling Drift
Drift happens when someone edits DNS outside Terraform. Run a scheduled plan, for example nightly in CI, with -detailed-exitcode. It exits with code 2 when there are changes, which you can turn into an alert:
terraform plan -input=false -detailed-exitcode
status=$?
if [ "$status" -eq 2 ]; then
echo "DNS drift detected" >&2
exit 1
fi
exit "$status"
Some records are deliberately managed elsewhere, such as ACME _acme-challenge TXT records created by certificate tools or records created by Kubernetes ExternalDNS. Keep those out of Terraform entirely rather than fighting over them.
Terraform Alternatives for DNS
Terraform is the most common choice, but two DNS-specific tools are worth knowing:
- OctoDNS: Defines zones in YAML and syncs them to one or more providers. It's popular for multi-provider setups.
- DNSControl: Uses a JavaScript-based configuration file and supports many providers, with a preview step similar to
terraform plan.
Both are designed around the idea that one source of truth can drive several providers, which can be simpler than Terraform for pure DNS work. Terraform shines when DNS records need to reference other infrastructure, such as a load balancer's hostname or a new server's IP address.
FAQ: DNS as Code With Terraform
cloudflare_record is the DNS record resource in version 4 of the Cloudflare Terraform provider. In version 5 it was replaced by cloudflare_dns_record, and the value attribute became content. Cloudflare publishes migration guidance for moving existing state between them.
No. Importing only records the existing resources in Terraform state. Nothing changes until you apply a plan, so check that the plan shows no changes after import before applying anything.
Yes. Configure each provider and define records for each one. You can drive both from the same local map to keep them identical, which is a common approach for multi-provider redundancy.
The next terraform plan shows the difference and proposes changing the record back to what the code says. Scheduled plans with detailed exit codes help you catch this drift quickly.
Most should, especially static records such as websites, email and verification records. Records created dynamically by other systems, such as ACME challenges or Kubernetes ExternalDNS, are better left to those systems.
Yes. OpenTofu supports the same provider configuration, resources and import blocks shown here, and uses the same providers from its registry.
Conclusion
Managing DNS as code with Terraform turns risky manual edits into reviewed, versioned and repeatable changes. Configure your provider with a scoped token, keep state in a locked remote backend, define records with stable keys and for_each, and use import blocks to adopt existing zones without downtime.
From there, run plans in CI, protect critical records with prevent_destroy, and check for drift on a schedule. Pay attention to provider versions, especially the Cloudflare v4 to v5 changes, and your DNS will become one of the most predictable parts of your infrastructure.


