ferkakta.dev

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

#terraform #dns #cloudflare #route53 #aws