Route53 requires one record set per name+type — your Terraform module needs to aggregate
Route53 models record sets. Cloudflare models individual records. If you treat them the same in Terraform, your module will work on Cloudflare and fail on Route53.
My DNS module creates one aws_route53_record per record key in the input map. I had an SPF TXT record and two Google site verification TXT records, all at the apex. Three record keys, same name, same type.
Cloudflare handled it fine — CF allows separate records per name+type. Route53 does not. R53 enforces one record set per (name, type) combination. The terraform apply failed:
InvalidChangeBatch: [Tried to create resource record set [name='fizz.today.', type='TXT'] but it already exists]
The workaround (ugly)
Jam all values into one record key at the caller:
records = merge(local.fastmail_records["fizz.today"], {
spf = {
name = ""
type = "TXT"
values = [
"v=spf1 include:spf.messagingengine.com ?all",
"google-site-verification=vw44dxRva...",
"google-site-verification=Nr0NWXz4...",
]
}
})
This leaks R53’s constraint into the caller. The caller has to know that R53 can’t handle separate TXT records at the same name, and manually merge. If callers need to understand provider quirks, your abstraction is leaking.
The fix (module-level aggregation)
Group records by (name, type) and merge their values before creating R53 resources:
r53_aggregated = {
for group_key, records in {
for key, record in var.records :
"${record.name}/${record.type}" => record...
} :
group_key => {
name = records[0].name == "" ? var.domain : "${records[0].name}.${var.domain}"
type = records[0].type
ttl = max([for r in records : r.ttl]...)
values = flatten([for r in records : r.values])
}
}
The => record... grouping syntax collects all records with the same name/type key into a list. Then we flatten their values into one list and take the highest TTL.
The R53 resource uses this aggregated map:
resource "aws_route53_record" "records" {
for_each = local.r53_enabled ? local.r53_aggregated : {}
# ...
records = each.value.values
}
Cloudflare’s local (cf_records) stays untouched — it already fans out to one resource per individual value, which CF handles natively.
State migration
The resource keys change from record key (spf) to group key (/TXT). Existing deployments will see Terraform wanting to destroy and recreate. Use terraform state mv or moved blocks to avoid the churn.