An AWS Route 53 setup trips people on exactly two things, and both fail silently. First, NS delegation: you create a hosted zone in Route 53 but never change the nameservers at your domain registrar, so the world keeps asking your registrar's DNS and never sees a single record you created. Second, the apex: you try to point example.com (no www) at a CloudFront or load balancer hostname with a CNAME, and either the console rejects it or your zone quietly breaks because a CNAME is illegal at the zone apex. The fix is to delegate the four Route 53 nameservers at your registrar, then use an ALIAS record (not a CNAME) at the apex. This is the full, correct setup, in the order you should do it.
Create the hosted zone first
A hosted zone is the container for every record for one domain. Create it before you touch your registrar, because creating it is what generates the four nameservers you are about to delegate to. Do it once and leave it; a public hosted zone costs USD 0.50 per month regardless of traffic, and deleting and recreating it gives you a brand new set of nameservers, which means redoing delegation and waiting out propagation again.
# --caller-reference just has to be unique per request; a timestamp works.
aws route53 create-hosted-zone \
--name example.com \
--caller-reference "example.com-$(date +%s)"
# Pull the four NS records Route 53 assigned to this zone.
aws route53 get-hosted-zone --id /hostedzone/Z0123456789ABCDEFGHIJ \
--query 'DelegationSet.NameServers'That second command returns something like the list below. These four hostnames are the entire point of the next step. Note the zone ID (the Z0123... value) too; every CLI call that changes records needs it.
- ns-148.awsdns-18.com
- ns-1726.awsdns-23.co.uk
- ns-911.awsdns-49.net
- ns-1262.awsdns-29.org
Delegate the nameservers at your registrar (the step everyone forgets)
This is the step that makes Route 53 authoritative for your domain, and it does not happen in AWS. Log in to wherever you bought the domain (Namecheap, GoDaddy, Cloudflare Registrar, Google Domains' successor, whoever) and replace the existing nameservers with the four Route 53 nameservers from the previous step. You are looking for a field labelled 'Nameservers' or 'Custom DNS', not the registrar's own DNS record editor. Enter all four. Drop the trailing dot if your registrar's form rejects it; ns-148.awsdns-18.com and ns-148.awsdns-18.com. are the same name.
Until you do this, every record you create in Route 53 is invisible. I have watched people spend an afternoon debugging an A record that was perfect, when the real problem was that the domain's parent zone still pointed at the registrar's nameservers. Verify delegation has taken from the parent's point of view, not from your zone:
# Ask the .com TLD servers who is authoritative for your domain.
# +trace follows the chain from the root; the final NS answer
# should list the awsdns names, not your old registrar's.
dig NS example.com +trace
# Or query a public resolver directly:
dig NS example.com @8.8.8.8 +shortRegistrar NS changes can take anywhere from minutes to 48 hours to be visible everywhere because the TLD's own records have their own TTL. This is the one delay you genuinely cannot rush, so do delegation first and let it bake while you build the records.
Why an apex record must be an ALIAS, not a CNAME
The zone apex is your bare domain, example.com, with no subdomain in front. DNS rules (RFC 1034) forbid a CNAME coexisting with other records at the same name, and the apex always has NS and SOA records, so a CNAME at the apex is illegal. That is a problem the moment you want example.com to point at a CloudFront distribution or an Application Load Balancer, because AWS gives you a hostname (d111abc.cloudfront.net), never a fixed IP, so a plain A record with an IP is not an option either.
Route 53's answer is the ALIAS record, an AWS-specific extension that is not part of standard DNS. It looks like an A record to the outside world (it resolves to an IP), but instead of a hard-coded address you point it at an AWS target and Route 53 resolves the underlying IPs for you, keeping up as they change. Three properties matter in production:
- It is legal at the apex. An ALIAS A record coexists with the NS and SOA records, so example.com with no www finally works.
- Alias queries to AWS targets are free. Route 53 does not charge for queries answered by an alias to a CloudFront, ALB, S3 website, or another Route 53 record, unlike standard queries which are billed per million.
- No TTL of your own. You omit TTL on an alias; Route 53 manages it from the target. Do not try to set one, the API rejects an alias that also carries TTL or ResourceRecords.
A CNAME at the apex is not a config you can tune, it is a rule of DNS you cannot break. Reach for an ALIAS the moment the record is your bare domain.
Write the records with change-resource-record-sets
Every record change in Route 53 goes through a single API call, change-resource-record-sets, fed a JSON change batch. UPSERT creates the record if it is absent and overwrites it if it exists, which is what you almost always want so re-running a script is safe. Below is one batch that sets up an apex ALIAS to a CloudFront distribution, a www CNAME, MX for email, and the TXT records for SPF, DKIM, and DMARC. If you are putting a static site behind CloudFront on this domain, the distribution side of that is its own job; I walk through it in hosting an S3 static site on CloudFront with a custom domain.
{
"Comment": "Initial records for example.com",
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "example.com.",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "d111abcdef8.cloudfront.net.",
"EvaluateTargetHealth": false
}
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "www.example.com.",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{ "Value": "example.com." }]
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "example.com.",
"Type": "MX",
"TTL": 3600,
"ResourceRecords": [
{ "Value": "10 inbound-smtp.eu-west-1.amazonaws.com." }
]
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "example.com.",
"Type": "TXT",
"TTL": 3600,
"ResourceRecords": [
{ "Value": "\"v=spf1 include:amazonses.com -all\"" }
]
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "_dmarc.example.com.",
"Type": "TXT",
"TTL": 3600,
"ResourceRecords": [
{ "Value": "\"v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com\"" }
]
}
}
]
}A few things that bite people in that file. The HostedZoneId inside AliasTarget for any CloudFront distribution is always the literal Z2FDTNDATAQYW2, a global constant, not your own zone ID and not the CloudFront distribution ID. For an ALB it is the load balancer's region-specific zone ID, which you get from describe-load-balancers. TXT values must be wrapped in escaped double quotes inside the JSON string, which is why SPF looks like \"v=spf1 ...\". And note DKIM is not in this batch by hand: if you verify the domain in Amazon SES with Easy DKIM, SES gives you three CNAME records to add as their own UPSERT changes rather than a TXT you write yourself. Apply the batch:
aws route53 change-resource-record-sets \
--hosted-zone-id Z0123456789ABCDEFGHIJ \
--change-batch file://change-batch.json
# The call returns a ChangeInfo with an Id and Status PENDING.
# Block until Route 53 has pushed the change to all its servers:
aws route53 wait resource-record-sets-changed --id /change/C0987654321ZYXWVUTSRQTTL choices, propagation, and failover health checks
TTL is how long resolvers cache an answer, and it is a trade-off you set per record. A high TTL (3600s and up) means fewer queries and lower cost but slow changes; a low TTL (60 to 300s) means changes take effect fast but you pay for more lookups. The practical move is to drop a record's TTL to 60 a day before a planned migration, make the cutover, confirm it, then raise it back up. The 'propagation' you wait on after a record change is really just resolvers worldwide aging out their cached copy, bounded by the old TTL, not by Route 53, which itself goes INSYNC within seconds.
Health checks for failover
If you run a primary and a standby, Route 53 health checks plus a failover routing policy will pull a dead endpoint out of DNS automatically. You create a health check that probes an HTTP path, then attach two records of the same name: a PRIMARY record with the health check, and a SECONDARY record. When the primary's check fails, Route 53 starts answering with the secondary.
aws route53 create-health-check \
--caller-reference "hc-example-$(date +%s)" \
--health-check-config '{
"Type": "HTTPS",
"FullyQualifiedDomainName": "origin.example.com",
"Port": 443,
"ResourcePath": "/health",
"RequestInterval": 30,
"FailureThreshold": 3
}'One catch with alias records and failover: setting EvaluateTargetHealth to true on an alias to an ALB makes Route 53 inherit the target's own health rather than needing a separate check, which is the cleaner setup when the target is an AWS resource that already reports health. Reserve standalone health checks for origins Route 53 cannot introspect, like an EC2 instance behind a plain A record.
Email and TLS land on the same records
Two follow-ons depend on the records above being right. Email: the MX, SPF, DKIM, and DMARC records are what get your mail accepted instead of binned, and getting them complete is most of the deliverability battle; I cover the rest in the email deliverability guide for developers. TLS: if you are issuing a certificate through AWS Certificate Manager with DNS validation, ACM hands you a CNAME to add to this same zone, and that whole free-certificate flow is its own walkthrough in free SSL on AWS with ACM. Both are just more UPSERT changes in the same hosted zone.
The order is the whole trick. Create the hosted zone, delegate its four nameservers at your registrar and verify with dig before you trust anything, then build records knowing the apex is always an ALIAS and never a CNAME. Get those two right and Route 53 stops being mysterious; it is a fast, scriptable, authoritative DNS service. Get either wrong and you will burn an afternoon staring at records that are perfect but that nobody on the internet is allowed to ask you about.

