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_each map 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 plan on pull requests and apply on merge, using API tokens scoped to a single zone.
Zone-as-code delivery pipeline Records defined in a Git repository are validated and planned in CI, gated by review, then applied to the DNS provider; a nightly drift job re-plans the zone and opens a change back in the repository. From records.yaml to authoritative answer Git repo records.yaml CI validate fmt + plan Review gate destroy = stop Apply scoped token push diff merge Nightly drift job plan -detailed-exitcode re-plan exit 2 Console edits surface as a drift plan, never as a silent revert on the next apply

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.

Import path from live zone to Terraform state Records exported from the live zone are matched by import blocks to resource addresses, which populates state so the following plan is empty. One import block per published record Live zone export @ A 203.0.113.10 www CNAME edge… MX 10 mx1… import blocks to = …record["@|A"] id = zone/record-id generate-config-out Terraform state 3 resources tracked plan: No changes unmatched = destroy match apply Any published record without an import block is a record Terraform will propose to delete

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.

Plan action classes and the destroy gate A plan splits into create, in-place update, and destroy or replace actions; the first two apply automatically while destructive changes require a lifecycle guard and manual approval. terraform plan resource_changes[] create new name, no risk update in place value or TTL only destroy / replace name gap possible additive safe gated CI applies on merge no human in the loop prevent_destroy + approval targeted plan, second reviewer Read the summary line first: anything other than zero destroys needs a reason in the pull request

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.

Back to DNS Zone Management