Terraform module for multi-provider DNS: define once, deploy to Route53 + Cloudflare
I manage 10 domains across Route53 and Cloudflare. When I set up multi-provider DNS on my first domain, every record had to be defined twice — once for each provider. The APIs are different enough that you can’t just copy-paste.
The duplication got old fast. So I wrote a module.
The problem
Route53 and Cloudflare represent the same DNS data differently:
MX records: Route53 bundles priority into the value string ("10 mx1.example.com"). Cloudflare splits it into a separate priority field.
TXT records: Route53 groups all values into one record set. Cloudflare needs one resource per value.
Apex records: Route53 uses alias {} blocks for A/AAAA pointing at AWS resources (CloudFront, ALB). Cloudflare uses CNAME with automatic apex flattening. Same result, completely different API.
NS mirroring: For multi-provider DNS to work, each provider needs the other’s nameservers registered. That’s 4 NS records per domain.
Multiply all of this by 10 domains, and you’re maintaining hundreds of nearly-identical resources that drift out of sync the moment you forget to update one side.
The module
terraform-multi-provider-dns takes one canonical record definition and creates it in whichever providers are enabled. The caller writes records once in Route53 format. The module translates.
Caller side
module "dns" {
source = "git::ssh://git@github.com/fizz/terraform-multi-provider-dns.git?ref=v0.1"
domain = "fizz.today"
r53_zone_id = data.aws_route53_zone.main.zone_id
r53_nameservers = data.aws_route53_zone.main.name_servers
cloudflare_zone_id = var.cloudflare_zone_id
records = {
mx = {
name = ""
type = "MX"
values = ["10 in1-smtp.messagingengine.com", "20 in2-smtp.messagingengine.com"]
}
dkim_fm1 = {
name = "fm1._domainkey"
type = "CNAME"
values = ["fm1.fizz.today.dkim.fmhosted.com"]
}
txt = {
name = ""
type = "TXT"
values = [
"v=spf1 include:spf.messagingengine.com ?all",
"google-site-verification=vw44dxRva-wc9Pw8aZKrUYUaYnnAdMbIFAY1Z4k1Szo",
]
}
dmarc = {
name = "_dmarc"
type = "TXT"
values = ["v=DMARC1; p=none; rua=mailto:549d2fec9d324050bdacc454f02e20fe@dmarc-reports.cloudflare.net"]
}
}
alias_records = {
apex = {
name = ""
types = ["A", "AAAA"]
target = var.cloudfront_domain
r53_hosted_zone = "Z2FDTNDATAQYW2"
}
}
}
That’s it. Both providers get the right resources in their native format. Change a record in one place, both providers update.
How the translation works
The module’s locals.tf handles the format differences:
locals {
# Cloudflare: flatten to one resource per individual value
cf_records = merge([
for key, record in var.records : {
for idx, value in record.values :
"${key}_${idx}" => {
name = record.name == "" ? var.domain : record.name
type = record.type
ttl = record.ttl
proxied = record.proxied
# MX: parse "10 mx1.example.com" -> priority + content
priority = record.type == "MX" ? tonumber(split(" ", value)[0]) : null
content = record.type == "MX" ? trimprefix(value, "${split(" ", value)[0]} ") : value
}
}
]...)
}
Route53 gets var.records directly — it handles multi-value natively. Cloudflare gets the flattened version with MX priorities parsed and TXT values split into individual resources.
For alias records, Route53 creates A/AAAA records with alias {} blocks. Cloudflare creates a single CNAME — apex flattening handles the rest transparently.
Provider enablement
Set a zone ID to enable a provider. Leave it null to skip:
# R53 only
r53_zone_id = aws_route53_zone.main.zone_id
cloudflare_zone_id = null # default, creates no CF resources
# Both providers
r53_zone_id = aws_route53_zone.main.zone_id
cloudflare_zone_id = "9492bfb2fdbd83a400972d72f14c3b53"
# Cloudflare only (theoretically)
r53_zone_id = null
cloudflare_zone_id = "9492bfb2fdbd83a400972d72f14c3b53"
There’s also enable_r53 and enable_cloudflare booleans for when zone IDs aren’t known until apply (the for_each known-after-apply problem).
The multi-provider discovery
When both providers are enabled, the module registers Route53 nameservers as NS records in Cloudflare. But there’s a catch I didn’t find documented anywhere obvious: Cloudflare silently ignores NS records at the zone apex unless you enable a zone-level setting called multi_provider.
The setting isn’t in the registrar dashboard. It’s in the DNS zone settings, and even there it’s easy to miss. But there’s an API:
curl -X PATCH \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"multi_provider": true}' \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_settings"
Better yet, there’s a Terraform resource:
resource "cloudflare_zone_dns_settings" "multi_provider" {
zone_id = var.cloudflare_zone_id
multi_provider = true
}
The module does this automatically when both providers are active. It’s gated on local.multi_provider (true when both zone IDs are set), and the NS records depends_on the setting so they’re created in the right order.
Without this, you create 4 NS records in Cloudflare, dig shows only 2 Cloudflare nameservers, and you wonder why Route53 isn’t answering. Ask me how I know.
Adding a provider
The module is designed so adding a provider means adding one .tf file and one variable. The canonical record format doesn’t change, existing callers don’t break.
terraform-multi-provider-dns/
variables.tf # canonical record format + per-provider zone IDs
locals.tf # flattening/parsing
r53.tf # aws_route53_record resources
cloudflare.tf # cloudflare_dns_record resources
ns_mirroring.tf # cross-provider NS + multi_provider setting
outputs.tf
versions.tf
# Future:
# google.tf # google_dns_record_set
Google Cloud DNS, Dyn, whatever — same pattern. Parse the canonical format into the provider’s native format, gate on the zone ID variable.
Verification
After deploying to both providers, you want to confirm they match. I wrote a parity script that fetches both zones and diffs them side by side:
Record Route53 Cloudflare
-------------------------------------------------- -------------------- --------------------
fizz.today MX pri 10 TTL 300 TTL 300
fizz.today MX pri 20 TTL 300 TTL 300
fizz.today TXT v=spf1 include:spf.messagin TTL 300 TTL 300
_dmarc.fizz.today TXT v=DMARC1; p=none; rua=m TTL 300 TTL auto
(3 apex alias/CNAME records suppressed — R53 A/AAAA aliases and CF CNAME flattening are equivalent)
The >>> markers on gaps jump right out. The script automatically detects when Route53 has A/AAAA aliases and Cloudflare has a CNAME to the same target — these are the same thing expressed differently, so it suppresses them instead of false-flagging.
Results
7 parked domains and 2 active sites, all managed from one module. Zero dashboard clicks. Every domain gets 6 nameservers (2 Cloudflare + 4 Route53), both providers authoritative, fully managed in Terraform.
$ dig fizz.today NS +short
ns-336.awsdns-42.com.
ns-859.awsdns-43.net.
ns-1417.awsdns-49.org.
ns-1549.awsdns-01.co.uk.
lila.ns.cloudflare.com.
seth.ns.cloudflare.com.
The module is Apache 2.0 licensed: github.com/fizz/terraform-multi-provider-dns