Moving a Route 53 hosted zone between AWS accounts
Posted in Aws, Dns, Networking, Cli
By Dušan Dželebdžić

I wanted to move ddz.dev from one AWS account to another. Just the Route 53 hosted zone. I opened the console, looked for a record export, and got stuck before I'd moved anything.
The CLI export was straightforward. The part that made me pause came later, after I'd already imported the records: two aliases contained a field called HostedZoneId.
I'd just created a zone with a different ID. Did those two fields need changing too?
In this case, they needed to stay exactly as they were.
Create a new zone and copy the records
Route 53's account migration procedure creates a new hosted zone, copies the records, and switches delegation. Transferring a domain registration is a separate operation and doesn't transfer its hosted zone. AWS documents that distinction here.
My domain was registered at Name.com. That registration stayed there; the DNS hosting account was what changed.
Create a public hosted zone for the same domain in the destination account. Keep its generated NS and SOA records and note its new zone ID.
To export, open CloudShell in the source account. It already has the CLI and uses the account you're signed into. CloudShell setup.
aws route53 list-resource-record-sets \
--hosted-zone-id OLD_ZONE_ID \
--output json > records.jsonReplace OLD_ZONE_ID with your source hosted zone ID. The CLI handles pagination automatically, so don't add --no-paginate or a record limit to this export. Command reference.
Keep that original file. It's your comparison point later.
The exported JSON needs a different wrapper
The export contains a ResourceRecordSets array. The import command expects a Changes array, with an action and a singular ResourceRecordSet for each entry.
This conversion also removes only the NS and SOA records at the zone's own name:
jq --arg apex 'ddz.dev.' '{
Changes: [
.ResourceRecordSets[]
| select((.Name == $apex and (.Type == "NS" or .Type == "SOA")) | not)
| {Action: "CREATE", ResourceRecordSet: .}
]
}' records.json > import.jsonUse your own domain, including the trailing dot that Route 53 puts in exported names. A child NS record such as staging.ddz.dev. is a delegation and must survive the filter. The new zone already supplies its own apex NS and SOA. AWS migration procedure.
This command changes the JSON structure. It doesn't migrate account-specific dependencies or repair alias references. Inspect those before importing.
An alias's HostedZoneId can belong to the target service
My batch contained 19 record sets: 17 ordinary records and two aliases.
| Record | Target | Alias HostedZoneId |
|---|---|---|
api.ddz.dev | API Gateway in us-east-1 | Z1UJRXOUMOOFQ8 |
vault.ddz.dev | S3 website endpoint in us-west-2 | Z3BJ6K6RIION7M |
Those IDs describe the targets. For example, AWS lists Z3BJ6K6RIION7M for the Oregon S3 website endpoint in its S3 endpoint reference.
The AliasTarget API reference defines HostedZoneId according to the target type. For an API Gateway regional custom domain, it uses the API's regional hosted zone ID. For an S3 website, it uses the website endpoint's zone ID.
Both service targets stayed the same in my migration, so both alias IDs stayed the same. Moving the DNS records didn't move the API or the S3 bucket to the new account.
An alias pointing at another record inside the hosted zone needs different treatment: replace its old zone ID with the destination zone ID and create its target first. Alias chains need that ordering throughout. AWS's alias migration instructions.
That was the useful distinction. Replacing every field named HostedZoneId would have changed perfectly valid service references.
Import into the destination account
Upload the prepared file to CloudShell in the destination account, then run:
aws route53 change-resource-record-sets \
--hosted-zone-id NEW_ZONE_ID \
--change-batch file://import.jsonReplace NEW_ZONE_ID with the zone you just created. The batch uses CREATE, so an existing record causes an error instead of being silently overwritten. Route 53 applies a valid batch together, or rejects it. Import command reference.
This was a small zone. Large exports may exceed the request limits of 1,000 resource records or 32,000 characters across all Value elements and need batching. Those limits aren't simply a count of JSON objects. Route 53 quotas.
For zones using health checks or Traffic Flow, follow the additional migration steps for those dependencies. This example had neither.
At this point, I had imported the records but hadn't changed the nameservers. The original zone was still serving the live domain.
Ask the new nameservers directly
Querying the domain through my usual resolver would mostly tell me whether the old zone still worked. To check the destination, I asked each of its four nameservers directly.
For example, these were two of the queries against one of my new servers:
dig @ns-2031.awsdns-61.co.uk ddz.dev A +norecurse
dig @ns-2031.awsdns-61.co.uk ddz.dev MX +norecurseUse a nameserver from your destination zone. In the response, check for status: NOERROR, the aa authoritative-answer flag, and the expected record values.
For ddz.dev, the checks covered all 17 ordinary record sets on all four servers. All 68 comparisons matched the export, including mail routing, SPF, DKIM, DMARC, and certificate validation records. The four NS sets matched too.
Both aliases also returned authoritative address answers from all four servers. Their address lists varied between queries, which is why I didn't treat identical AWS service IP lists as the success condition.
These were DNS checks. They didn't establish that an API request succeeded or that a test email arrived.
Check the parent DS record before switching
I wasn't sure whether DNSSEC was configured. A validating public resolver returned no DS record for ddz.dev:
dig @8.8.8.8 ddz.dev DS +dnssecThe response had NOERROR, no DS answer, and the ad flag with a signed denial from the parent zone. That established that there was no public DS delegation to remove. It didn't tell me whether the old zone itself signed responses.
If a DS record exists, request its removal through the registrar, confirm that the parent has removed it, and then wait out the previous DS TTL before changing the signing setup or delegation. AWS's DNSSEC disable procedure.
Re-establish trust only after the old delegation caches have expired and the new zone is signing correctly. Follow the DNSSEC enablement procedure, including its record-cache waiting period, before publishing the destination's DS.
An empty dig +short result on its own isn't enough evidence. A timeout or failed query needs investigating too.
Replace the nameservers at the registrar
At Name.com that lives under My Domains: pick the domain, open Domain Actions, then Manage Nameservers. I replaced the old set with all four servers from the destination hosted zone and saved. Name.com's instructions.
After the change, I queried a .dev registry nameserver directly:
dig @ns-tld1.charlestonroadregistry.com ddz.dev NS \
+norecurse +noall +answer +authorityThe referral contained the new four-server set. Google and Cloudflare's public resolvers also returned the new nameservers, plus the expected website A record and mail MX record. That's a useful sample of the cutover, not proof that every resolver has refreshed.
AWS recommends lowering delegation-related TTLs ahead of migration and allowing the old TTL to expire. Lowering a TTL at cutover doesn't shorten copies already cached. TTL preparation.
Keep the old hosted zone intact for at least 48 hours after switching, and longer if previous delegation TTLs require it. Apply any intervening record changes to both zones. AWS's overlap guidance.
The missing export button got me into the CLI. The field called HostedZoneId was where I needed to slow down and look at what the record actually pointed to.