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:
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:
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 type | Typical TTL we use | Why |
|---|---|---|
| App routing records (A/CNAME) | 300 | Faster rollback and safer cutovers |
| Email/auth records (MX/SPF/DMARC) | 3600 | More stable, less resolver churn |
| Alias records to AWS targets | Managed by alias target | Route 53 alias behavior handles this differently |
Creating Records
The Terraform resources use for_each to create records from the configuration:
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:
{
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):
{
name = ""
type = "MX"
ttl = 3600
records = [
"10 mx1.improvmx.com.",
"20 mx2.improvmx.com."
]
}SPF Records
Sender Policy Framework prevents email spoofing:
{
name = ""
type = "TXT"
ttl = 3600
records = ["v=spf1 include:_spf.google.com ~all"]
}DKIM Records
For email signing (example with Mailtrap):
{
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:
{
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):
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:
{
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:
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:
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
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:
# 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:
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 tfplanPull 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:
| Check | Why it matters |
|---|---|
| TTL values are appropriate | Lower TTLs reduce risk during cutovers and rollback |
| Trailing dots are correct for MX/CNAME targets | Avoids accidental relative-name resolution |
Record name is correct (including apex "") | Prevents writing the right record to the wrong name |
| Email flows still resolve correctly | Bad MX/TXT changes can stop inbound or outbound mail |
| Certificate validation records remain intact | Missing validation records can break renewals |
Testing DNS Changes
Before merging, verify with dig:
# 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 MXRoute 53 changes are usually fast (under a minute), but DNS caching means old records persist until TTL expires.
Cost
Route 53 pricing:
| Cost item | Price |
|---|---|
| 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.