AWS Route 53 - Street-Level Ops¶
Real-world Route 53 operational workflows for DNS migrations, failover setup, debugging, and hybrid architectures.
Migrating DNS to Route 53¶
The most common Route 53 operation: moving an existing domain from another DNS provider.
# Step 1: Export your existing zone file from the current provider
# Most providers offer zone file export (BIND format).
# If not, manually list all records.
# Step 2: Create the hosted zone in Route 53
ZONE_ID=$(aws route53 create-hosted-zone \
--name example.com \
--caller-reference "migration-$(date +%s)" \
--query 'HostedZone.Id' --output text | sed 's|/hostedzone/||')
echo "Zone ID: $ZONE_ID"
# Step 3: Get the NS records Route 53 assigned
aws route53 get-hosted-zone --id $ZONE_ID \
--query 'DelegationSet.NameServers[]' --output text
# Example output:
# ns-1234.awsdns-56.org
# ns-789.awsdns-01.co.uk
# ns-456.awsdns-23.com
# ns-012.awsdns-78.net
# Step 4: BEFORE updating the registrar, lower TTLs at your current provider
# Set all records to TTL 60-300 seconds
# Wait at least the OLD TTL duration (e.g., if it was 86400, wait 24 hours)
# Step 5: Import records into Route 53
# Create a JSON change batch for all your records:
cat > /tmp/import-records.json << 'EOF'
{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "example.com",
"Type": "A",
"TTL": 300,
"ResourceRecords": [{"Value": "203.0.113.10"}]
}
},
{
"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 mail.example.com"},
{"Value": "20 mail2.example.com"}
]
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "example.com",
"Type": "TXT",
"TTL": 3600,
"ResourceRecords": [
{"Value": "\"v=spf1 include:_spf.google.com ~all\""}
]
}
}
]
}
EOF
aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch file:///tmp/import-records.json
# Step 6: Verify records resolve correctly via Route 53 NS directly
dig @ns-1234.awsdns-56.org example.com A +short
dig @ns-1234.awsdns-56.org www.example.com CNAME +short
dig @ns-1234.awsdns-56.org example.com MX +short
dig @ns-1234.awsdns-56.org example.com TXT +short
# Step 7: Update NS records at the registrar to the Route 53 name servers
# This is done in the registrar's web UI — not via CLI.
# Replace existing NS records with the four Route 53 NS records.
# Step 8: Monitor propagation
watch -n 30 'dig example.com NS +short'
# Wait until you see the Route 53 NS records consistently.
# Full propagation can take 24-48 hours.
# Step 9: After propagation is complete, raise TTLs to production values
Setting Up Failover Between Regions¶
Active-passive failover with health checks.
# Step 1: Create health checks for both regions
PRIMARY_HC=$(aws route53 create-health-check \
--caller-reference "primary-hc-$(date +%s)" \
--health-check-config '{
"Type": "HTTPS",
"FullyQualifiedDomainName": "us-east-1-alb.example.com",
"Port": 443,
"ResourcePath": "/health",
"RequestInterval": 10,
"FailureThreshold": 3
}' --query 'HealthCheck.Id' --output text)
SECONDARY_HC=$(aws route53 create-health-check \
--caller-reference "secondary-hc-$(date +%s)" \
--health-check-config '{
"Type": "HTTPS",
"FullyQualifiedDomainName": "us-west-2-alb.example.com",
"Port": 443,
"ResourcePath": "/health",
"RequestInterval": 10,
"FailureThreshold": 3
}' --query 'HealthCheck.Id' --output text)
# Step 2: Tag the health checks (for identification)
aws route53 change-tags-for-resource \
--resource-type healthcheck --resource-id $PRIMARY_HC \
--add-tags Key=Name,Value=primary-us-east-1
aws route53 change-tags-for-resource \
--resource-type healthcheck --resource-id $SECONDARY_HC \
--add-tags Key=Name,Value=secondary-us-west-2
# Step 3: Create failover records
aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch '{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "app.example.com",
"Type": "A",
"SetIdentifier": "primary-us-east-1",
"Failover": "PRIMARY",
"AliasTarget": {
"HostedZoneId": "Z35SXDOTRQ7X7K",
"DNSName": "us-east-1-alb.example.com",
"EvaluateTargetHealth": true
},
"HealthCheckId": "'$PRIMARY_HC'"
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "app.example.com",
"Type": "A",
"SetIdentifier": "secondary-us-west-2",
"Failover": "SECONDARY",
"AliasTarget": {
"HostedZoneId": "Z1H1FL5HABSF5",
"DNSName": "us-west-2-alb.example.com",
"EvaluateTargetHealth": true
},
"HealthCheckId": "'$SECONDARY_HC'"
}
}
]
}'
# Step 4: Test the failover by checking health check status
aws route53 get-health-check-status --health-check-id $PRIMARY_HC \
--query 'HealthCheckObservations[].{Region:Region,Status:StatusReport.Status}'
Weighted Routing for Canary Deploys¶
Shift a percentage of traffic to a new version.
# Start: 100% to v1, 0% to v2
# Then: 95/5, 90/10, 75/25, 50/50, 0/100
# Create weighted records
aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch '{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "app.example.com",
"Type": "A",
"SetIdentifier": "v1-stable",
"Weight": 95,
"AliasTarget": {
"HostedZoneId": "Z35SXDOTRQ7X7K",
"DNSName": "v1-alb.example.com",
"EvaluateTargetHealth": true
}
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "app.example.com",
"Type": "A",
"SetIdentifier": "v2-canary",
"Weight": 5,
"AliasTarget": {
"HostedZoneId": "Z35SXDOTRQ7X7K",
"DNSName": "v2-alb.example.com",
"EvaluateTargetHealth": true
}
}
}
]
}'
# Shift traffic gradually — update weights
# To go to 50/50:
aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch '{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "app.example.com",
"Type": "A",
"SetIdentifier": "v1-stable",
"Weight": 50,
"AliasTarget": {
"HostedZoneId": "Z35SXDOTRQ7X7K",
"DNSName": "v1-alb.example.com",
"EvaluateTargetHealth": true
}
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "app.example.com",
"Type": "A",
"SetIdentifier": "v2-canary",
"Weight": 50,
"AliasTarget": {
"HostedZoneId": "Z35SXDOTRQ7X7K",
"DNSName": "v2-alb.example.com",
"EvaluateTargetHealth": true
}
}
}
]
}'
# Rollback: set canary weight to 0
# Note: weight 0 means Route 53 never returns that record (unless all others are 0 too)
Debugging Route 53 Resolution¶
When DNS is not resolving as expected.
# 1. Check what Route 53 thinks the records are
aws route53 list-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--query "ResourceRecordSets[?Name=='app.example.com.']"
# 2. Test resolution directly against Route 53 name servers
# (bypasses caching resolvers)
NS=$(aws route53 get-hosted-zone --id $ZONE_ID \
--query 'DelegationSet.NameServers[0]' --output text)
dig @$NS app.example.com A +short
dig @$NS app.example.com A +trace
# 3. Test resolution from the Route 53 resolver's perspective
aws route53 test-dns-answer \
--hosted-zone-id $ZONE_ID \
--record-name app.example.com \
--record-type A
# This shows exactly what Route 53 would return, including
# routing policy evaluation and health check status
# 4. Check health check status (if routing policy depends on it)
aws route53 list-health-checks \
--query 'HealthChecks[].{Id:Id,Config:HealthCheckConfig.FullyQualifiedDomainName,Status:HealthCheckConfig.Type}'
# For each health check:
aws route53 get-health-check-status --health-check-id hc-abc123 \
--query 'HealthCheckObservations[].{Region:Region,IP:IPAddress,Status:StatusReport.Status}'
# 5. Check query logs if enabled
aws logs filter-log-events \
--log-group-name /aws/route53/example.com \
--filter-pattern "app.example.com" \
--start-time $(date -d '1 hour ago' +%s000) \
--limit 20 \
--query 'events[].message' --output text
# 6. Check from external resolvers
dig @8.8.8.8 app.example.com A +short # Google
dig @1.1.1.1 app.example.com A +short # Cloudflare
dig @208.67.222.222 app.example.com A +short # OpenDNS
# If Route 53 NS answers correctly but these do not, it is a caching/TTL issue
Private Hosted Zone Across Accounts¶
Share a private hosted zone with VPCs in other AWS accounts.
# In the account that owns the hosted zone:
# Authorize the other account's VPC to associate
aws route53 create-vpc-association-authorization \
--hosted-zone-id Z0987654321XYZ \
--vpc VPCRegion=us-east-1,VPCId=vpc-other-account-123
# In the OTHER account:
# Associate the VPC with the private hosted zone
aws route53 associate-vpc-with-hosted-zone \
--hosted-zone-id Z0987654321XYZ \
--vpc VPCRegion=us-east-1,VPCId=vpc-other-account-123
# Back in the owning account: revoke the authorization (one-time use)
aws route53 delete-vpc-association-authorization \
--hosted-zone-id Z0987654321XYZ \
--vpc VPCRegion=us-east-1,VPCId=vpc-other-account-123
# Verify the association
aws route53 get-hosted-zone --id Z0987654321XYZ \
--query 'VPCs[].{Region:VPCRegion,VPC:VPCId}'
Route 53 + CloudFront Setup¶
The standard pattern for serving a static site or CDN-fronted app.
# Prerequisite: CloudFront distribution with an ACM certificate
# (certificate must be in us-east-1 for CloudFront)
# Create alias record pointing to CloudFront
aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch '{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "www.example.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "d1234567890.cloudfront.net",
"EvaluateTargetHealth": false
}
}
},
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "www.example.com",
"Type": "AAAA",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "d1234567890.cloudfront.net",
"EvaluateTargetHealth": false
}
}
}
]
}'
# Z2FDTNDATAQYW2 is always the hosted zone ID for CloudFront — it is a constant.
# Verify
dig www.example.com A +short
# Should return CloudFront edge IPs
Route 53 Resolver for Hybrid DNS¶
On-premises servers need to resolve AWS private hosted zones. AWS resources need to resolve on-prem domains.
# Scenario:
# On-prem DNS: corp.internal (10.100.1.53, 10.100.2.53)
# AWS private zone: aws.internal
# Goal: bidirectional resolution
# Step 1: Inbound endpoint (on-prem → AWS)
# On-prem DNS servers forward aws.internal queries here
INBOUND=$(aws route53resolver create-resolver-endpoint \
--creator-request-id "inbound-$(date +%s)" \
--name "on-prem-to-aws" \
--security-group-ids sg-resolver-inbound \
--direction INBOUND \
--ip-addresses \
SubnetId=subnet-priv1a,Ip=10.0.1.10 \
SubnetId=subnet-priv1b,Ip=10.0.2.10 \
--query 'ResolverEndpoint.Id' --output text)
echo "Configure on-prem DNS to forward aws.internal to 10.0.1.10 and 10.0.2.10"
# Step 2: Outbound endpoint (AWS → on-prem)
OUTBOUND=$(aws route53resolver create-resolver-endpoint \
--creator-request-id "outbound-$(date +%s)" \
--name "aws-to-on-prem" \
--security-group-ids sg-resolver-outbound \
--direction OUTBOUND \
--ip-addresses \
SubnetId=subnet-priv1a \
SubnetId=subnet-priv1b \
--query 'ResolverEndpoint.Id' --output text)
# Step 3: Create forwarding rule for on-prem domain
RULE=$(aws route53resolver create-resolver-rule \
--creator-request-id "fwd-corp-$(date +%s)" \
--name "forward-corp-internal" \
--rule-type FORWARD \
--domain-name "corp.internal" \
--resolver-endpoint-id $OUTBOUND \
--target-ips "Ip=10.100.1.53,Port=53" "Ip=10.100.2.53,Port=53" \
--query 'ResolverRule.Id' --output text)
# Step 4: Associate the rule with VPCs
aws route53resolver associate-resolver-rule \
--resolver-rule-id $RULE \
--vpc-id vpc-abc123
# Step 5: Verify from an EC2 instance in the VPC
# SSH to the instance and run:
dig server1.corp.internal +short
# Should resolve to the on-prem IP
# Step 6: Verify from on-prem (after configuring conditional forwarder)
# On Windows DNS: Add conditional forwarder for aws.internal → 10.0.1.10
# On BIND: zone "aws.internal" { type forward; forwarders { 10.0.1.10; 10.0.2.10; }; };
Calculating Route 53 Costs¶
# Hosted zones: $0.50/month each
# Queries (standard): $0.40 per million (first 1B/month)
# Queries (latency-based): $0.60 per million
# Queries (geo): $0.70 per million
# Health checks (basic): $0.50/month (AWS endpoint), $0.75/month (non-AWS)
# Health checks (HTTPS): add $1.00/month
# Health checks (string match): add $2.00/month
# Health checks (fast interval 10s): doubles the cost
# Traffic flow policy record: $50/month
# Example: 3 hosted zones, 10M queries/month, 4 health checks (HTTPS, 10s)
ZONES=3
QUERIES_M=10
HC_COUNT=4
ZONE_COST=$(echo "$ZONES * 0.50" | bc)
QUERY_COST=$(echo "$QUERIES_M * 0.40" | bc)
HC_COST=$(echo "$HC_COUNT * (0.75 + 1.00) * 2" | bc) # non-AWS, HTTPS, fast
echo "Zones: \$$ZONE_COST/month"
echo "Queries: \$$QUERY_COST/month"
echo "Health: \$$HC_COST/month"
echo "Total: \$$(echo "$ZONE_COST + $QUERY_COST + $HC_COST" | bc)/month"
# Zones: $1.50, Queries: $4.00, Health: $14.00 = $19.50/month
Bulk Record Management with CLI and Terraform¶
For managing dozens or hundreds of records, use the CLI batch API or Terraform.
# CLI: batch upsert from a JSON file
# The change batch supports up to 1000 changes per call
# Generate a change batch from a CSV
cat records.csv
# name,type,ttl,value
# app.example.com,A,300,203.0.113.10
# api.example.com,A,300,203.0.113.20
# cdn.example.com,CNAME,3600,d123.cloudfront.net
# Convert CSV to Route 53 change batch
python3 -c "
import csv, json, sys
changes = []
for row in csv.DictReader(open('records.csv')):
changes.append({
'Action': 'UPSERT',
'ResourceRecordSet': {
'Name': row['name'],
'Type': row['type'],
'TTL': int(row['ttl']),
'ResourceRecords': [{'Value': row['value']}]
}
})
json.dump({'Changes': changes}, sys.stdout, indent=2)
" > /tmp/batch-changes.json
aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch file:///tmp/batch-changes.json
# Check the change status
CHANGE_ID=$(aws route53 change-resource-record-sets \
--hosted-zone-id $ZONE_ID \
--change-batch file:///tmp/batch-changes.json \
--query 'ChangeInfo.Id' --output text)
aws route53 get-change --id $CHANGE_ID \
--query 'ChangeInfo.Status'
# PENDING → INSYNC (usually within 60 seconds)
Terraform approach for infrastructure-as-code DNS management:
# Terraform: manage Route 53 records declaratively
resource "aws_route53_zone" "main" {
name = "example.com"
}
resource "aws_route53_record" "app" {
zone_id = aws_route53_zone.main.zone_id
name = "app.example.com"
type = "A"
alias {
name = aws_lb.app.dns_name
zone_id = aws_lb.app.zone_id
evaluate_target_health = true
}
}
resource "aws_route53_health_check" "app" {
fqdn = "app.example.com"
port = 443
type = "HTTPS"
resource_path = "/health"
failure_threshold = 3
request_interval = 10
tags = {
Name = "app-health-check"
}
}