fizz.today

Cloudflare Registrar locks your nameservers (and how to escape with multi-provider DNS)

Registered a domain at Cloudflare. Wanted to move DNS to Route53. Went to change nameservers and — there’s no option. Cloudflare Registrar hardcodes its own nameservers and won’t let you point elsewhere.

The problem

Most registrars have a “custom nameservers” field. Cloudflare Registrar does not. If you register (or transfer) a domain to Cloudflare, you’re locked to Cloudflare DNS. The NS records are immutable.

This is the trade-off for $0 registration fees and at-cost renewals. The DNS is the product.

The workaround

Cloudflare has a feature called multi-provider DNS. It’s buried in DNS settings for the zone. When enabled, Cloudflare will serve NS records for other providers alongside its own.

The setup:

  1. Create your Route53 hosted zone. Note the 4 NS records AWS gives you.
  2. In Cloudflare DNS settings, enable multi-provider DNS.
  3. Add 4 NS records in Cloudflare pointing to Route53’s nameservers:
fizz.today  NS  ns-336.awsdns-42.com
fizz.today  NS  ns-1549.awsdns-01.co.uk
fizz.today  NS  ns-859.awsdns-43.net
fizz.today  NS  ns-1417.awsdns-49.org

Now DNS resolvers get both Cloudflare and Route53 NS records. Either can answer queries. You manage your records in Route53 (or both, if you want belt-and-suspenders).

What this looks like in practice

$ 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.

Six nameservers. Two providers. Both authoritative.

Managing it with Terraform

Both zones managed in one module with hashicorp/aws and cloudflare/cloudflare providers.

First, you need a Cloudflare API token. Go to Profile → API Tokens and create a custom token with:

That gives Terraform the minimum permissions to manage DNS records without access to anything else in your Cloudflare account. Pass it to the provider:

provider "cloudflare" {
  api_token = var.cloudflare_api_token  # from terraform.tfvars, gitignored
}

Then the NS records:

resource "cloudflare_dns_record" "route53_ns" {
  for_each = toset([
    "ns-336.awsdns-42.com",
    "ns-1549.awsdns-01.co.uk",
    "ns-859.awsdns-43.net",
    "ns-1417.awsdns-49.org",
  ])

  zone_id = var.cloudflare_zone_id
  name    = var.domain
  type    = "NS"
  content = each.key
  ttl     = 86400
}

Record duplication

Here’s the catch: resolvers pick a nameserver at random from the NS set. If a resolver picks a Cloudflare NS and asks for a record that only exists in Route53, it gets nothing.

You have two options:

  1. Duplicate every record in both providers. Belt and suspenders. Every query gets an answer regardless of which NS the resolver picks. More Terraform to manage, but bulletproof.

  2. Keep records in one provider only and accept split-brain risk. In practice, if Route53 has your MX records and Cloudflare doesn’t, roughly half of MX lookups will fail depending on which NS the resolver chose.

I went with option 1 for anything that matters — the apex record pointing to CloudFront exists in both zones (Route53 as an A/AAAA alias, Cloudflare as a CNAME with flattening), and ACM validation CNAMEs are in both. MX, DKIM, and SPF records are also in Route53, so they need Cloudflare copies too if you want reliable email delivery.

The Terraform module manages both zones, so the duplication is just more resource blocks — not extra operational burden.

Verifying parity

Once you have records in both providers, you want to confirm they match. This script fetches both zones and produces a side-by-side comparison table:

#!/usr/bin/env bash
# dns-parity.sh — compare Route53 and Cloudflare zones side by side
# Usage: R53_ZONE_ID=Z0XXX CFLARE_ZONE_ID=xxx CFLARE_API_TOKEN=xxx ./dns-parity.sh

set -euo pipefail

r53=$(aws route53 list-resource-record-sets \
  --hosted-zone-id "$R53_ZONE_ID" --output json)

cflare=$(curl -s -H "Authorization: Bearer $CFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones/$CFLARE_ZONE_ID/dns_records?per_page=100")

python3 - "$r53" "$cflare" <<'PYEOF'
import json, sys

r53 = json.loads(sys.argv[1])
cflare = json.loads(sys.argv[2])

def parse_r53(data):
    records = {}
    for r in data["ResourceRecordSets"]:
        name = r["Name"].rstrip(".")
        rtype = r["Type"]
        if "AliasTarget" in r:
            key = f"{name} {rtype}"
            records[key] = f"ALIAS -> {r['AliasTarget']['DNSName'].rstrip('.')}"
        else:
            ttl = r.get("TTL", "")
            for rec in r.get("ResourceRecords", []):
                val = rec["Value"]
                # Collapse MX priority into the key
                if rtype == "MX":
                    parts = val.split(" ", 1)
                    key = f"{name} {rtype} pri {parts[0]}"
                    val = parts[1] if len(parts) > 1 else val
                else:
                    key = f"{name} {rtype} {val[:30]}"
                records[key] = f"TTL {ttl}"
    return records

def parse_cflare(data):
    records = {}
    for r in data["result"]:
        name = r["name"]
        rtype = r["type"]
        ttl = r.get("ttl", "")
        ttl_str = "auto" if ttl == 1 else str(ttl)
        content = r["content"]
        if rtype == "MX":
            key = f"{name} {rtype} pri {r.get('priority','')}"
        else:
            key = f"{name} {rtype} {content[:30]}"
        records[key] = f"TTL {ttl_str}"
    return records

r53_recs = parse_r53(r53)
cflare_recs = parse_cflare(cflare)
all_keys = sorted(set(r53_recs) | set(cflare_recs))

# Detect apex A/AAAA alias (Route53) vs CNAME-flattened (Cloudflare).
# These are equivalent — only flag MISSING if one side is truly absent.
def find_apex_equivalences(r53_recs, cflare_recs):
    """Find apex names where R53 has A/AAAA aliases and CF has a CNAME
    pointing to the same target. These are parallel records, not gaps."""
    suppress = set()
    r53_aliases = {}  # name -> set of targets
    for key, val in r53_recs.items():
        parts = key.split(" ", 2)
        if len(parts) >= 2 and parts[1] in ("A", "AAAA") and val.startswith("ALIAS"):
            target = val.split("-> ", 1)[1] if "-> " in val else ""
            r53_aliases.setdefault(parts[0], set()).add(target)

    for key, val in cflare_recs.items():
        parts = key.split(" ", 2)
        if len(parts) >= 3 and parts[1] == "CNAME":
            name = parts[0]
            cflare_target = parts[2]
            if name in r53_aliases:
                # Both sides have the apex covered — suppress all three
                for rkey in r53_recs:
                    rparts = rkey.split(" ", 2)
                    if rparts[0] == name and rparts[1] in ("A", "AAAA"):
                        suppress.add(rkey)
                suppress.add(key)
    return suppress

suppress = find_apex_equivalences(r53_recs, cflare_recs)

print(f"{'Record':<50s} {'Route53':<20s} {'Cloudflare':<20s}")
print(f"{'-'*50} {'-'*20} {'-'*20}")
for key in all_keys:
    if key in suppress:
        continue
    r = r53_recs.get(key, "MISSING")
    c = cflare_recs.get(key, "MISSING")
    marker = ">>> " if r == "MISSING" or c == "MISSING" else "    "
    print(f"{marker}{key:<50s} {r:<20s} {c:<20s}")

if suppress:
    print(f"\n({len(suppress)} apex alias/CNAME records suppressed — "
          f"R53 A/AAAA aliases and CF CNAME flattening are equivalent)")
PYEOF

Output looks like:

     Record                                             Route53              Cloudflare
     -------------------------------------------------- -------------------- --------------------
     fizz.today MX pri 10                               TTL 300              TTL 300
     fizz.today MX pri 20                               TTL 300              TTL 300
>>>  _dmarc.fizz.today TXT v=DMARC1; p=none; rua=m     MISSING              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. This is how I caught that my DMARC record was in Cloudflare but missing from Route53.

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 crying wolf. But if only one side has apex records, it still flags the gap.

Grab the script: dns-parity.sh

Why this matters

If you’re on Cloudflare Registrar and need Route53 for ACM certificate validation, CloudFront aliases, or just prefer AWS-native DNS — multi-provider DNS is your escape hatch. You don’t have to transfer the domain to a different registrar.

The feature is easy to miss. It’s not in the registrar settings. It’s in the DNS zone settings, and it only appears after you toggle it on.

#dns #cloudflare #route53 #terraform