DNS Management at Scale - Route 53 with Terraform

DNS Management at Scale - Route 53 with Terraform architecture diagram
Click to expand
992 × 483px

DNS is critical infrastructure. A bad deployment can take down your entire online presence. That's why DNS lives in its own dedicated account, managed through its own repository.

This post covers Route 53 zone management, common DNS patterns, and how DNS integrates with the broader account structure.

Why a Dedicated DNS Account?

Separating DNS provides:

Blast radius isolation: A misconfigured deployment to the website account can't accidentally delete your MX records.

Clear ownership: DNS changes go through one repository with its own review process.

Cross-account access: Multiple accounts can manage their own DNS records through scoped roles.

Billing clarity: DNS costs are visible separately from compute workloads.

Zone-Centric Configuration

We organise DNS configuration by zone. Each domain gets a complete definition:

hcl
variable "dns_zones" {
  type = map(object({
    zone_id = string
    comment = optional(string)
    records = optional(list(object({
      name    = string
      type    = string
      ttl     = optional(number, 300)
      records = optional(list(string), [])
    })))
  }))
}

And the actual configuration:

hcl
dns_zones = {
  "example.com" = {
    zone_id = "Z1234567890ABC"
    comment = "Primary domain"
    records = [
      {
        name    = ""
        type    = "A"
        ttl     = 300
        records = ["1.2.3.4"]
      },
      {
        name    = "www"
        type    = "CNAME"
        ttl     = 300
        records = ["example.com"]
      },
      # ... more records
    ]
  }
}

Everything for a domain is together, making it easy to understand and review.

TTL defaults also deserve a clear convention:

Record typeTypical TTL we useWhy
App routing records (A/CNAME)300Faster rollback and safer cutovers
Email/auth records (MX/SPF/DMARC)3600More stable, less resolver churn
Alias records to AWS targetsManaged by alias targetRoute 53 alias behavior handles this differently

Creating Records

The Terraform resources use for_each to create records from the configuration:

hcl
resource "aws_route53_zone" "zones" {
  for_each = var.dns_zones

  name    = each.key
  comment = each.value.comment

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_route53_record" "records" {
  for_each = {
    for record in flatten([
      for zone_name, zone in var.dns_zones : [
        for record in coalesce(zone.records, []) : {
          key     = "${zone_name}-${record.name}-${record.type}"
          zone_id = zone.zone_id
          name    = record.name
          type    = record.type
          ttl     = record.ttl
          records = record.records
        }
      ]
    ]) : record.key => record
  }

  zone_id = each.value.zone_id
  name    = each.value.name
  type    = each.value.type
  ttl     = each.value.ttl
  records = each.value.records
}

The prevent_destroy lifecycle rule on zones is critical. Accidentally deleting a hosted zone means losing all records and potentially the domain's delegation.

Email Configuration Patterns

Most domains need email configuration:

MX Records

For Google Workspace:

hcl
{
  name    = ""
  type    = "MX"
  ttl     = 3600
  records = [
    "1 ASPMX.L.GOOGLE.COM.",
    "5 ALT1.ASPMX.L.GOOGLE.COM.",
    "5 ALT2.ASPMX.L.GOOGLE.COM.",
    "10 ALT3.ASPMX.L.GOOGLE.COM.",
    "10 ALT4.ASPMX.L.GOOGLE.COM."
  ]
}

For ImprovMX (email forwarding):

hcl
{
  name    = ""
  type    = "MX"
  ttl     = 3600
  records = [
    "10 mx1.improvmx.com.",
    "20 mx2.improvmx.com."
  ]
}

SPF Records

Sender Policy Framework prevents email spoofing:

hcl
{
  name    = ""
  type    = "TXT"
  ttl     = 3600
  records = ["v=spf1 include:_spf.google.com ~all"]
}

DKIM Records

For email signing (example with Mailtrap):

hcl
{
  name    = "rwmt1._domainkey"
  type    = "CNAME"
  ttl     = 300
  records = ["rwmt1.dkim.smtp.mailtrap.live."]
},
{
  name    = "rwmt2._domainkey"
  type    = "CNAME"
  ttl     = 300
  records = ["rwmt2.dkim.smtp.mailtrap.live."]
}

DMARC Records

Domain-based Message Authentication:

hcl
{
  name    = "_dmarc"
  type    = "TXT"
  ttl     = 3600
  records = ["v=DMARC1; p=none; rua=mailto:dmarc@example.com"]
}

Website Records

For websites hosted on CloudFront or other services:

Alias Records

For AWS resources, use alias records (no TTL needed, optional target health evaluation):

hcl
resource "aws_route53_record" "website_alias" {
  zone_id = aws_route53_zone.zones["example.com"].zone_id
  name    = ""
  type    = "A"

  alias {
    name                   = "d1234567890.cloudfront.net"
    zone_id                = "Z2FDTNDATAQYW2"  # CloudFront's hosted zone
    evaluate_target_health = false
  }
}

Vercel/Netlify Deployments

For external hosting platforms:

hcl
{
  name    = "app"
  type    = "CNAME"
  ttl     = 300
  records = ["cname.vercel-dns.com."]
}

Importing Existing Zones

The DNS account existed before the Organizations setup. We imported zones rather than recreating them:

hcl
import {
  to = aws_route53_zone.zones["example.com"]
  id = "Z1234567890ABC"
}

The import ID is the zone ID, which you can find in the Route 53 console.

For records:

hcl
import {
  to = aws_route53_record.records["example.com--MX"]
  id = "Z1234567890ABC_example.com_MX"
}

The import ID format is ZONEID_RECORDNAME_TYPE. For the apex domain, the record name is the domain itself (e.g., example.com).

Cross-Account DNS for Certificate Validation

aws-account-structure-part-10-dns/dns-cross-account diagram
Click to expand
732 × 626px

Certificates (via ACM) often need DNS validation records. When the certificate is in a different account than DNS:

The cert-manager Pattern

For Kubernetes cert-manager to validate Let's Encrypt certificates:

hcl
# IAM user in DNS account
resource "aws_iam_user" "certmanager" {
  provider = aws.dns_management
  name     = "certmanager-dns-challenge"
  path     = "/service-accounts/"
}

# Role with minimal DNS permissions
resource "aws_iam_role" "certmanager_dns" {
  provider = aws.dns_management
  name     = "CertManagerDNSRole"

  assume_role_policy = jsonencode({
    # Allow the certmanager user to assume this role
  })
}

resource "aws_iam_role_policy" "certmanager_dns" {
  provider = aws.dns_management
  role     = aws_iam_role.certmanager_dns.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "route53:GetChange"
        Resource = "arn:aws:route53:::change/*"
      },
      {
        Effect = "Allow"
        Action = [
          "route53:ChangeResourceRecordSets",
          "route53:ListResourceRecordSets"
        ]
        Resource = "arn:aws:route53:::hostedzone/Z1234567890ABC"
        Condition = {
          "ForAllValues:StringEquals" = {
            "route53:ChangeResourceRecordSetsRecordTypes" = ["TXT"]
          }
          "ForAllValues:StringLike" = {
            "route53:ChangeResourceRecordSetsRecordNames" = ["_acme-challenge.*"]
          }
        }
      },
      {
        Effect = "Allow"
        Action = [
          "route53:ListHostedZones",
          "route53:ListHostedZonesByName"
        ]
        Resource = "*"
      }
    ]
  })
}

The conditions scope access to:

  • Only TXT records (ACME challenges use TXT)
  • Only _acme-challenge.* subdomains
  • Only the specified hosted zone

This is as minimal as possible while still allowing cert-manager to function.

CI/CD for DNS

DNS changes flow through the same GitHub Actions pipeline as other infrastructure:

yaml
name: DNS Infrastructure

on:
  push:
    branches: [main]
    paths: ['tf/**']
  pull_request:
    branches: [main]
    paths: ['tf/**']

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: eu-west-2

      - name: Assume DNS deployment role
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/DNSDeploymentRole
          role-external-id: ${{ secrets.DNS_DEPLOYMENT_EXTERNAL_ID }}
          role-chaining: true
          aws-region: eu-west-2

      - name: Deploy
        run: |
          cd tf
          tofu init
          tofu plan -out=tfplan
          tofu apply tfplan

Pull requests show the DNS changes that will be made. Merging to main applies them.

In production, this is a dedicated cross-account hop: CI authenticates in the management account via OIDC, then assumes a scoped DNS deployment role in the DNS account. Keeping that second role DNS-only has prevented accidental non-DNS privilege creep.

DNS Change Reviews

DNS changes deserve extra scrutiny. A wrong MX record means no email. A wrong A record means website downtime.

The review checklist:

CheckWhy it matters
TTL values are appropriateLower TTLs reduce risk during cutovers and rollback
Trailing dots are correct for MX/CNAME targetsAvoids accidental relative-name resolution
Record name is correct (including apex "")Prevents writing the right record to the wrong name
Email flows still resolve correctlyBad MX/TXT changes can stop inbound or outbound mail
Certificate validation records remain intactMissing validation records can break renewals

Testing DNS Changes

Before merging, verify with dig:

bash
# Check current state
dig example.com MX
dig example.com TXT
dig www.example.com CNAME

# After deployment, verify propagation
dig @8.8.8.8 example.com MX

Route 53 changes are usually fast (under a minute), but DNS caching means old records persist until TTL expires.

Cost

Route 53 pricing:

Cost itemPrice
Hosted zones$0.50/month per zone
Queries$0.40 per million queries (first billion)

For small-scale usage, it's a few dollars per month.

What's Next

With DNS managed as code in its own account, the organisational infrastructure is complete. The final piece is the workload all of this exists to serve.

Part 11 covers the AWS Amplify setup, cross-account deployment roles for the website account, and the domain verification handshake between the website and DNS pipelines.


← Back to all posts