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:
- Create your Route53 hosted zone. Note the 4 NS records AWS gives you.
- In Cloudflare DNS settings, enable multi-provider DNS.
- 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:
- Permissions: Zone → DNS → Edit
- Zone Resources: Include → Specific zone → your domain
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:
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.
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.