Type something to search...
How to manage DNS as code with Terraform?

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 MX record 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 plan shows 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:

  • ttl is required in v5. Use 1 for 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.

Tags :
Share :

Related Posts

What is the difference between an A record and a CNAME record?

What is the difference between an A record and a CNAME record?

The difference between an A record and a CNAME record is what they point to. An A record points a hostname directly at an IPv4 address, such

Dive Deeper
What are the best DNS providers for websites?

What are the best DNS providers for websites?

The best DNS providers for most websites are Cloudflare for a fast, free, easy starting point, Amazon Route 53 if you already run on AWS, **G

Dive Deeper
What are the best public DNS resolvers?

What are the best public DNS resolvers?

The best public DNS resolvers for most people are Cloudflare (1.1.1.1) for speed and privacy, Google Public DNS (8.8.8.8) for reliability and

Dive Deeper