Managing DNS Zones as Code with Terraform
After working through this guide you will be able to take a live production zone that someone has been editing by hand, bring it under Terraform without a single resolution gap, express every record as data rather than as copy-pasted resource blocks, and hand the apply step to CI with a token that can only touch that one zone.
Zone-as-code fails in practice for a boring reason: the first terraform apply is run against a zone that Terraform has never seen, so the plan proposes to create records that already exist and to delete the ones it does not know about. Everything in this guide is arranged around avoiding that moment. You import first, you model records as a map so the diff stays readable, you make destructive plans impossible to apply by accident, and only then do you let a pipeline run apply unattended. The same shape works whether your authority sits on Cloudflare, Route 53, or both at once.
Key implementation objectives:
- Import an existing zone into state so the first plan is a genuine no-op.
- Drive every record from one
for_eachmap instead of hand-written resource blocks per name. - Make a destroy or replace of the apex or the nameserver set fail the pipeline rather than pass silently.
- Run
planon pull requests andapplyon merge, using API tokens scoped to a single zone.
Prerequisites and environment setup
Terraform 1.5 introduced import blocks and 1.6 added -generate-config-out, so pin at least 1.6; the examples below were written against 1.9. You also need a remote backend with locking, because two people running apply against the same zone at the same time is exactly how you end up with half a record set. S3 with native lockfile support, Terraform Cloud, or GCS all work.
terraform version # Terraform v1.9.x
dig +short NS example.com # confirm which provider is actually authoritative
Create the credentials before you touch any HCL. On Cloudflare, mint a token with Zone:DNS:Edit and Zone:Zone:Read limited to the one zone — not an account-wide token, and never the legacy global API key. On AWS, attach a policy that allows route53:ChangeResourceRecordSets, route53:ListResourceRecordSets and route53:GetChange on arn:aws:route53:::hostedzone/Z123456ABCDEFG only.
export CLOUDFLARE_API_TOKEN='…' # read by the Cloudflare provider automatically
export AWS_PROFILE=dns-admin # assumes a role limited to one hosted zone
A workable module layout separates the record data from the plumbing, so a record change is a one-line diff in a data file that a reviewer can read without knowing HCL:
dns/
├── main.tf # providers, backend, locals that load the YAML
├── versions.tf # required_version + provider constraints
├── imports.tf # import blocks, deleted once state is populated
├── zones/
│ ├── example.com.yaml
│ └── example.net.yaml
└── modules/
└── zone/ # for_each over the record map, one per provider
Step-by-step procedure
Step 1 — Import the live zone before you write a single record
Export what is actually published first. On Cloudflare the export endpoint returns a BIND-format file; on Route 53 the record sets come back as JSON. Treat that export as the source of truth for the import, not the dashboard as you remember it.
curl -s "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/export" \
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" -o live-zone.txt
aws route53 list-resource-record-sets --hosted-zone-id Z123456ABCDEFG \
--output json > live-zone.json
Then write import blocks. The declarative form is far better than the old terraform import command because the imports live in version control, run inside a normal plan, and can generate the matching configuration for you.
import {
to = cloudflare_dns_record.record["www|A"]
id = "${var.zone_id}/372e67954025e0ba6aaa6d586b9e0b59"
}
import {
to = aws_route53_record.record["app|A"]
id = "Z123456ABCDEFG_app.example.com_A"
}
Cloudflare record IDs come from the export or from GET /dns_records; Route 53 uses the underscore-joined composite of hosted zone, name, and type. Generate a first draft of the HCL rather than typing it:
terraform plan -generate-config-out=generated.tf
The side effect that matters: after a successful terraform apply of the import blocks, the next plan must report No changes. If it reports anything else, the generated configuration does not match reality yet — fix the configuration, never the zone.
Step 2 — Model records as data, not as resource blocks
Once state is populated, replace the generated per-record blocks with a single resource driven by for_each. The map key encodes name and type so a record can change its value without changing its address — that is what keeps an update an update instead of a destroy-and-create. The data file, zones/example.com.yaml, is the only thing most changes touch:
records:
- { name: "@", type: A, ttl: 300, value: "203.0.113.10" }
- { name: www, type: CNAME, ttl: 300, value: "edge.example.net" }
- { name: app, type: A, ttl: 60, value: "198.51.100.25" }
- { name: "@", type: MX, ttl: 3600, value: "mx1.mailprovider.com", priority: 10 }
- { name: "@", type: TXT, ttl: 3600, value: "v=spf1 include:_spf.mailprovider.com -all" }
locals {
zone = yamldecode(file("${path.module}/zones/example.com.yaml"))
records = {
for r in local.zone.records :
"${r.name}|${r.type}|${try(r.priority, 0)}|${substr(sha1(r.value), 0, 6)}" => r
}
}
resource "cloudflare_dns_record" "record" {
for_each = local.records
zone_id = var.zone_id
name = each.value.name
type = each.value.type
content = each.value.value
ttl = each.value.ttl
priority = try(each.value.priority, null)
proxied = try(each.value.proxied, false)
}
The value hash in the key looks odd but earns its place: Cloudflare treats a record’s name and type as non-unique, so two MX records at the apex need distinct keys. On Route 53 the model is different — one resource holds the whole record set, so multiple values live in a list and the key needs only name and type:
resource "aws_route53_record" "record" {
for_each = { for r in local.rrsets : "${r.name}|${r.type}" => r }
zone_id = var.hosted_zone_id
name = each.value.name
type = each.value.type
ttl = each.value.ttl
records = each.value.values
}
This difference is the single biggest source of surprise when a team runs both providers: adding a second MX on Cloudflare creates a resource, while on Route 53 it mutates one. Model the YAML as record sets and expand it for Cloudflare rather than the other way round.
Step 3 — Keep the plan diff readable
A plan is only a control if a reviewer can actually read it. Two habits do most of the work. First, keep TTLs explicit in the data file so a TTL change shows up as a one-line diff rather than hiding inside a provider default — the trade-offs are covered in Mastering TTL Strategies, and lowering them ahead of a change is its own procedure in Lowering TTLs Safely Before a Provider Migration. Second, sort the YAML and enforce the ordering in CI, so a diff never mixes a real change with a reshuffle.
A healthy plan for a TTL cut on one record reads like this:
Terraform will perform the following actions:
# cloudflare_dns_record.record["app|A|0|9f2c1e"] will be updated in-place
~ resource "cloudflare_dns_record" "record" {
id = "8f4c0d21a1b34e0f9c6d2e77bb1a5f30"
name = "app.example.com"
~ ttl = 300 -> 60
# (5 unchanged attributes hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
0 to destroy is the line to read first. Anything else on a zone that is already live deserves an explanation before merge.
Step 4 — Make destructive plans hard to apply
Lifecycle rules are the cheapest guardrail available. Put prevent_destroy on the resources whose removal would take the domain off the internet — the apex address record, the nameserver set, and the mail records:
resource "aws_route53_record" "apex" {
zone_id = var.hosted_zone_id
name = "example.com"
type = "A"
alias {
name = aws_cloudfront_distribution.web.domain_name
zone_id = "Z2FDTNDATAQYW2"
evaluate_target_health = false
}
lifecycle {
prevent_destroy = true
}
}
Terraform then refuses to produce an applyable plan that removes it, and the pipeline fails loudly instead of quietly. Back that with a CI check that greps the plan JSON for delete actions:
terraform show -json tfplan \
| jq -e '[.resource_changes[] | select(.change.actions[] | . == "delete")] | length == 0' \
|| { echo "plan contains deletions — requires manual approval"; exit 1; }
For genuinely intended removals, run a targeted plan so the blast radius is one address: terraform plan -target='cloudflare_dns_record.record["old|CNAME|0|1a2b3c"]'. Targeting is a debugging tool, not a habit — an apply that skips the rest of the configuration leaves state and reality diverging again.
Step 5 — Apply from CI with a scoped token
The pipeline runs plan on every pull request and posts the diff, then runs apply only on the default branch. Nobody holds long-lived DNS credentials on a laptop.
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
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/dns-terraform
aws-region: us-east-1
- run: terraform init -input=false
- run: terraform fmt -check -recursive
- run: terraform validate
- run: terraform plan -input=false -lock-timeout=120s -out=tfplan
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_DNS_TOKEN }}
- run: |
terraform show -json tfplan \
| jq -e '[.resource_changes[] | select(.change.actions[] == "delete")] | length == 0'
- if: github.ref == 'refs/heads/main'
run: terraform apply -input=false -lock-timeout=120s tfplan
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_DNS_TOKEN }}
The AWS credentials come from OIDC rather than a static access key, and the Cloudflare token is zone-scoped, so the worst a compromised job can do is edit one zone’s records — bad, but bounded, and reversible from Git.
Verification
Prove the loop is closed in three places: state, the provider, and the wire.
terraform plan -detailed-exitcode
Exit code 0 means no changes and is what a clean zone returns; 2 means drift; 1 is an error. Schedule the same command nightly and alert on 2.
Confirm the provider agrees with state, then confirm resolvers agree with the provider:
terraform state list | wc -l
dig +short @ns1.example.com app.example.com A
dig +noall +answer app.example.com A
app.example.com. 60 IN A 198.51.100.25
A TTL in the dig answer that does not match the value in your YAML means either the apply has not landed or the provider is overriding it — Cloudflare forces proxied records to a 300-second published TTL regardless of what you set, which is expected rather than drift.
Troubleshooting
The plan wants to destroy and recreate the apex
Almost always a key or attribute that Terraform treats as forcing replacement: renaming a map key, switching an apex record between a plain A and an alias, or moving example.com to example.com. in the name. Run terraform plan -json | jq '.. | .action_reason? // empty' to see why. The fix is moved blocks or terraform state mv to rename the address without touching the record, plus prevent_destroy on the apex so the mistake cannot be applied. Apex behavior differs sharply between vendors, which is worth reading up on in Apex Domain Setup on Cloudflare versus Route 53.
Provider-managed records fight Terraform
CDN validation records, ACM or ACME challenge tokens, and email vendor selectors are often created by another system. If Terraform does not know about them it proposes deletion; if it does know about them, the other system’s rotation shows as drift. Keep those names out of the Terraform-managed set entirely — either park them under a delegated subdomain whose NS records Terraform owns, or filter them out of the record map by prefix. Certificate automation in particular is easier when the _acme-challenge name is delegated, as described in Automating ACME DNS-01 Challenges for Wildcard Certificates.
Drift from a dashboard edit during an incident
The 3 a.m. console change is legitimate; leaving it uncodified is not. The nightly -detailed-exitcode job catches it within a day and should open a pull request containing the current live values rather than reverting them. Reverting an emergency fix automatically is how a resolved incident reopens itself. Where the provider supports it, disable dashboard editing for the service account and grant humans read-only access, so break-glass writes have to go through a deliberate role switch that leaves an audit trail.
State lock held by a dead run
A cancelled CI job can leave a lock in place, and every later run fails with a lock error. Read the lock ID from the error, confirm no apply is genuinely running, then terraform force-unlock <id>. Do not make this routine — set -lock-timeout=120s so transient contention resolves itself, and force-unlock only after checking the pipeline history.
Rate-limited APIs on a large zone
A zone with thousands of records can exceed provider request limits on first import or on a bulk refactor, surfacing as 429 or throttling errors mid-apply, which leaves the zone half-changed. Reduce concurrency with terraform apply -parallelism=2, split large zones into separate state files by subdomain, and prefer Route 53’s record-set model where one API call carries many values. When you are moving a whole zone rather than editing it, the transfer-based approach in Migrating DNS Zones Without Downtime Using Zone Transfers is far less API-intensive than replaying every record through Terraform.
Frequently Asked Questions
Do I need to import records, or can I just apply a matching configuration? You must import. Terraform only knows what is in state, so applying a configuration that describes existing records will try to create duplicates or, worse, replace them — briefly removing the name from the zone. Import blocks make the transition a no-op plan you can verify before anything changes.
Should the apex record live in Terraform at all?
Yes, but with prevent_destroy and ideally in its own resource rather than inside the for_each map. Keeping it addressed separately means a refactor of the record map can never change the apex’s resource address, which is the mechanism by which apex records get accidentally replaced.
How do I stop Terraform from reverting a record another system owns?
Do not manage that name. Delegate it to a subdomain with its own NS records, or exclude it from the map. ignore_changes helps for an attribute, but it will not stop a deletion proposal for a record that exists in the zone and not in state.
What happens if two pipelines apply at the same time? With a locking backend the second run waits for the lock and then plans against fresh state, which is correct. Without one, both plans are computed from a stale snapshot and the later apply can undo the earlier one. Never run DNS state on a local file backend shared over a drive.
Can one configuration manage Cloudflare and Route 53 together?
Yes, and it is common during a migration. Keep one YAML file per zone as the shared source of truth, then feed it to a per-provider module, remembering that Cloudflare models each record individually while Route 53 models record sets. Verify both authorities answer identically with dig before you move the delegation.
Related
- Migrating DNS Zones Without Downtime Using Zone Transfers
- Lowering TTLs Safely Before a Provider Migration
- Apex Domain Setup on Cloudflare versus Route 53
- Automating ACME DNS-01 Challenges for Wildcard Certificates
Back to DNS Zone Management