Guide · AWS Route 53 · MCP Server DNS · Custom Domains
Route 53 DNS for MCP Servers
AWS Route 53 is the DNS layer that sits in front of every production MCP server deployment — whether it runs on App Runner, ECS Fargate, EC2, or a bare VPS. Getting DNS right matters more for MCP servers than most HTTP services because MCP clients cache the server URL in agent configurations, workspace settings, and tool registries: a DNS misconfiguration that changes the hostname or causes intermittent failures breaks every agent that has saved the endpoint. Three things are critical for MCP server DNS: Alias records vs CNAME (Alias records resolve to AWS-managed IPs that Route 53 updates automatically when ALB or App Runner endpoints change; CNAME records add an extra resolution hop and cannot be used at the zone apex), TTL strategy (low TTLs during active deployments enable faster cutover; high TTLs reduce DNS query costs during stable periods), and health-check integration (Route 53 health checks linked to failover routing let DNS route around a failed MCP endpoint automatically).
TL;DR
Create a Route 53 public hosted zone for your domain with route53.create_hosted_zone(). Use Alias records (not CNAME) for ALB, CloudFront, and App Runner endpoints — Alias records are free (no per-query charge), work at the zone apex, and Route 53 resolves them to the current IPs automatically. Set TTL to 60s during deployments, then raise to 300s once stable. Link records to Route 53 health checks to enable automatic DNS failover when an MCP endpoint degrades.
Creating a hosted zone for an MCP server domain
A Route 53 public hosted zone holds all the DNS records for a domain. The zone creation API returns a set of NS (name server) records that you delegate to at your registrar:
import boto3
import uuid
route53 = boto3.client("route53")
# Create a public hosted zone for the MCP server domain
response = route53.create_hosted_zone(
Name="mcp-api.example.com",
# CallerReference must be unique per create call — use UUID
CallerReference=str(uuid.uuid4()),
HostedZoneConfig={
"Comment": "Public zone for MCP server endpoints",
"PrivateZone": False, # public zone; use True for VPC-internal only
},
)
hosted_zone_id = response["HostedZone"]["Id"]
# Format: /hostedzone/Z1234567890ABC
# Route 53 assigns 4 NS records — update these at your domain registrar
ns_records = response["DelegationSet"]["NameServers"]
print(f"Set NS records at registrar to: {ns_records}")
# e.g.: ['ns-100.awsdns-12.com', 'ns-200.awsdns-34.net', ...]
Name server propagation after registrar delegation typically takes 24–48 hours for global resolution. During that window, dig +trace mcp-api.example.com can verify that the NS delegation is resolving correctly from each level of the DNS hierarchy.
Alias records vs CNAME for AWS endpoints
Every AWS load balancer, CloudFront distribution, App Runner service, and S3 static site endpoint is expressed as an AWS-managed hostname (e.g., abc123.us-east-1.awsapprunner.com or my-alb-1234.us-east-1.elb.amazonaws.com). Route 53 Alias records are the correct DNS record type for these:
# Alias record for an Application Load Balancer (ALB)
# ALB DNS name comes from describe_load_balancers()["LoadBalancers"][0]["DNSName"]
# ALB hosted zone ID is region-specific — see Route 53 docs table
ALB_HOSTED_ZONE_IDS = {
"us-east-1": "Z35SXDOTRQ7X7K",
"us-east-2": "Z3AADJGX6KTTL2",
"us-west-2": "Z1H1FL5HABSF5",
"eu-west-1": "Z32O12XQLNTSW2",
"ap-southeast-1": "Z1LMS91P8CMLE5",
# ... see full table in Route 53 developer guide
}
route53.change_resource_record_sets(
HostedZoneId="/hostedzone/Z1234567890ABC",
ChangeBatch={
"Comment": "Route mcp.example.com to ALB",
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "mcp.example.com",
"Type": "A",
"AliasTarget": {
# ALB DNS name — NOT the IP addresses
"DNSName": "my-alb-1234567890.us-east-1.elb.amazonaws.com",
# Hosted zone ID for this ALB's region
"HostedZoneId": ALB_HOSTED_ZONE_IDS["us-east-1"],
# True = Route 53 evaluates the ALB's health and
# does not return the record if the ALB is unhealthy
"EvaluateTargetHealth": True,
}
}
}]
}
)
# Alias record for App Runner custom domain
# After associate_custom_domain(), App Runner returns the CNAME to create
# Use a CNAME (not Alias) for App Runner — App Runner does not have a hosted zone ID
route53.change_resource_record_sets(
HostedZoneId="/hostedzone/Z1234567890ABC",
ChangeBatch={
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "mcp.example.com",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [
{"Value": "abc123xyz.us-east-1.awsapprunner.com"}
],
}
}]
}
)
Key differences between Alias and CNAME records for MCP server deployments:
- Zone apex: You cannot use a CNAME at the zone apex (
example.comitself). If your MCP server is the primary subdomain, use an Alias A record. Only sub-subdomains (mcp.api.example.com) can use CNAME. - Query cost: Route 53 does not charge for Alias record resolution when the Alias target is an AWS endpoint in the same account. CNAME queries are billed at standard query rates ($0.40 per million).
- Resolution hops: CNAME adds a second DNS lookup — the resolver must first resolve the CNAME target. Alias records are resolved in a single step by Route 53, returning the underlying IPs directly to the resolver.
- Health evaluation: Setting
EvaluateTargetHealth: Trueon an Alias record causes Route 53 to check whether the AWS target (ALB, CloudFront) is healthy before returning the record. This is free and requires no separate health check resource.
TTL strategy for MCP server deployments
TTL (Time To Live) controls how long DNS resolvers cache the record before re-querying. For MCP servers, TTL is a deployment lever:
# TTL ladder for MCP server deployment workflow:
# Phase 1 — before any planned deployment (T-24h):
# Lower TTL so DNS changes take effect faster
route53.change_resource_record_sets(
HostedZoneId=HOSTED_ZONE_ID,
ChangeBatch={"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "mcp.example.com",
"Type": "CNAME",
"TTL": 60, # ← drop to 60s pre-deployment
"ResourceRecords": [{"Value": "old-endpoint.example.com"}],
}
}]}
)
# Phase 2 — at deployment (DNS cutover):
# Update the record to new endpoint
# Max propagation lag is now ~60s (current TTL)
route53.change_resource_record_sets(
HostedZoneId=HOSTED_ZONE_ID,
ChangeBatch={"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "mcp.example.com",
"Type": "CNAME",
"TTL": 60, # keep low until confirmed stable
"ResourceRecords": [{"Value": "new-endpoint.example.com"}],
}
}]}
)
# Phase 3 — after deployment confirmed stable (T+30min):
# Raise TTL to reduce Route 53 query costs and resolver load
route53.change_resource_record_sets(
HostedZoneId=HOSTED_ZONE_ID,
ChangeBatch={"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "mcp.example.com",
"Type": "CNAME",
"TTL": 300, # ← raise back to 5 min after stable confirmation
"ResourceRecords": [{"Value": "new-endpoint.example.com"}],
}
}]}
)
For Alias records pointing at ALB or CloudFront, Route 53 manages the actual IP resolution internally and the TTL you set applies to the Alias record in the resolver cache. AWS recommends 60s for Alias records where the underlying target may change IPs (such as ALB) and 300s for stable targets.
MCP client agents that cache the server hostname in configuration will not re-resolve DNS on every request — they cache the resolved IP for the duration of a session or process lifetime. This means DNS TTL is not your only propagation concern: long-running MCP agent processes may hold a stale IP for hours regardless of DNS TTL. Design zero-downtime deployments at the load balancer layer (rolling ECS tasks, App Runner service updates) rather than relying on DNS cutover to drain connections gracefully.
Registering custom domains for App Runner and ACM certificate validation
App Runner supports custom domains via the associate_custom_domain() API. Route 53 is required for the ACM certificate validation step:
import boto3
apprunner = boto3.client("apprunner", region_name="us-east-1")
route53 = boto3.client("route53")
# Step 1: Associate the custom domain with the App Runner service
assoc = apprunner.associate_custom_domain(
ServiceArn="arn:aws:apprunner:us-east-1:123456789012:service/mcp-server-prod/...",
DomainName="mcp.example.com",
EnableWWWSubdomain=False,
)
# Step 2: Create the CNAME record Route 53 returns for the App Runner endpoint
endpoint_cname = assoc["CustomDomain"]["DomainName"] # the *.awsapprunner.com hostname
route53.change_resource_record_sets(
HostedZoneId=HOSTED_ZONE_ID,
ChangeBatch={"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "mcp.example.com",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{"Value": endpoint_cname}],
}
}]}
)
# Step 3: Create ACM certificate validation CNAME records
# App Runner returns these as CertificateValidationRecords
# These allow ACM to prove domain control for TLS issuance
for record in assoc["CustomDomain"]["CertificateValidationRecords"]:
if record["Status"] == "PENDING_VALIDATION":
route53.change_resource_record_sets(
HostedZoneId=HOSTED_ZONE_ID,
ChangeBatch={"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": record["Name"],
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{"Value": record["Value"]}],
}
}]}
)
print(f"Created ACM validation CNAME: {record['Name']} → {record['Value']}")
# Step 4: Wait for certificate validation (typically 3-5 minutes after CNAME creation)
# Poll describe_custom_domains() until Status == ACTIVE
The ACM certificate validation CNAME records are permanent — do not delete them after validation completes. ACM re-validates certificates on renewal (every 13 months) using these same records. Deleting them breaks automatic renewal and will cause the App Runner TLS certificate to expire.
Querying and listing Route 53 records programmatically
Route 53 paginates record set responses. For MCP servers that programmatically manage their own DNS (e.g., a deployment script that verifies DNS state before cutover), pagination handling is essential:
def list_all_records(hosted_zone_id: str) -> list[dict]:
"""Return all resource record sets for a hosted zone, handling pagination."""
route53 = boto3.client("route53")
records = []
kwargs = {"HostedZoneId": hosted_zone_id, "MaxItems": "300"}
while True:
response = route53.list_resource_record_sets(**kwargs)
records.extend(response["ResourceRecordSets"])
if not response["IsTruncated"]:
break
# Pagination uses three fields together — all are required for the next page
kwargs["StartRecordName"] = response["NextRecordName"]
kwargs["StartRecordType"] = response["NextRecordType"]
if "NextRecordIdentifier" in response:
kwargs["StartRecordIdentifier"] = response["NextRecordIdentifier"]
return records
def find_mcp_endpoint_record(hosted_zone_id: str, hostname: str) -> dict | None:
"""Find the current DNS record for an MCP server hostname."""
for record in list_all_records(hosted_zone_id):
if record["Name"].rstrip(".") == hostname.rstrip("."):
return record
return None
# Verify DNS before cutover in a deployment script
existing = find_mcp_endpoint_record(HOSTED_ZONE_ID, "mcp.example.com")
if existing and "AliasTarget" in existing:
current_target = existing["AliasTarget"]["DNSName"]
print(f"Current DNS target: {current_target}")
elif existing:
current_target = existing["ResourceRecords"][0]["Value"]
print(f"Current DNS target (CNAME): {current_target}")
Monitor your MCP server DNS endpoint with AliveMCP
Route 53 routes DNS traffic to your MCP server, but DNS resolution success does not mean the MCP protocol is healthy. AliveMCP probes your custom domain endpoint every 60 seconds at the JSON-RPC layer — verifying that initialize and tools/list return valid responses, not just that the TCP port is open. Get alerted before users hit a broken MCP endpoint.