Guide · AWS Route 53 · Private DNS · MCP Service Discovery
Route 53 Private Hosted Zones for MCP Server Internal DNS
Route 53 private hosted zones provide VPC-scoped DNS that resolves only from within your VPC — enabling internal MCP service discovery, split-horizon DNS for dev/prod config parity, and human-readable hostnames for MCP microservices without exposing anything to the public internet. For MCP server deployments that span multiple services (an MCP gateway, a tool-executor, an auth server, a registry database), private hosted zones replace fragile IP-address configuration with stable DNS names that survive instance replacements, ECS task restarts, and rolling deployments. Three scenarios drive private hosted zone adoption for MCP teams: internal MCP services (an MCP gateway that calls internal tool-executor microservices using http://tool-executor.mcp.internal rather than hard-coded private IPs), split-horizon DNS (the same hostname resolves to a local dev server in the dev VPC and to the production endpoint in the prod VPC — eliminating environment-specific config files), and ECS Service Discovery (ECS tasks automatically register their private IPs with Route 53 as they start and deregister on termination).
TL;DR
Create a private hosted zone with route53.create_hosted_zone(Name="mcp.internal", HostedZoneConfig={"PrivateZone": True}) and associate it with your VPC. Add A or CNAME records for each internal MCP service. The VPC resolver at the VPC CIDR+2 address (e.g., 10.0.0.2) resolves private zone records only within that VPC. For dynamic service registration (ECS tasks, Lambda), use AWS Cloud Map which creates Route 53 records automatically as services start and stop.
Creating a private hosted zone for MCP internal services
Private hosted zones resolve only within the VPCs you explicitly associate them with. DNS queries from outside the VPC return NXDOMAIN:
import boto3, uuid
route53 = boto3.client("route53")
ec2 = boto3.client("ec2", region_name="us-east-1")
# Get the VPC ID for your MCP server VPC
vpc_id = "vpc-0abc1234567890def" # replace with your VPC ID
# Create the private hosted zone
response = route53.create_hosted_zone(
Name="mcp.internal", # zone name — use .internal to avoid conflicts
CallerReference=str(uuid.uuid4()),
HostedZoneConfig={
"Comment": "Internal DNS for MCP server services",
"PrivateZone": True, # critical: this makes it private
},
# Associate with VPC during creation
VPC={
"VPCRegion": "us-east-1",
"VPCId": vpc_id,
},
)
hosted_zone_id = response["HostedZone"]["Id"]
print(f"Private zone created: {hosted_zone_id}")
# Associate additional VPCs (e.g., a staging VPC in the same zone)
route53.associate_vpc_with_hosted_zone(
HostedZoneId=hosted_zone_id,
VPC={
"VPCRegion": "us-east-1",
"VPCId": "vpc-0staging1234567",
},
)
# Create DNS records for internal MCP services
services = [
("tool-executor.mcp.internal", "10.0.1.50"),
("auth-server.mcp.internal", "10.0.1.51"),
("registry-db.mcp.internal", "10.0.2.100"),
("mcp-gateway.mcp.internal", "10.0.1.10"),
]
for hostname, private_ip in services:
route53.change_resource_record_sets(
HostedZoneId=hosted_zone_id,
ChangeBatch={
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": hostname,
"Type": "A",
"TTL": 60,
"ResourceRecords": [{"Value": private_ip}],
}
}]
}
)
print(f"Created: {hostname} → {private_ip}")
The private zone name mcp.internal follows the convention of using .internal as a suffix — this namespace is reserved and will never conflict with a public TLD. Alternative conventions include .local (conflicts with mDNS on some systems), .corp, or using a subdomain of a real domain you own (e.g., internal.mcp-api.example.com — this allows split-horizon if you also have a public zone for mcp-api.example.com).
Split-horizon DNS for MCP dev/prod parity
Split-horizon DNS uses the same hostname in both development and production environments but resolves it to different endpoints depending on which VPC the query comes from. This eliminates environment-specific configuration in MCP server code:
# Split-horizon DNS setup:
# Both dev and prod MCP servers use "db.mcp.internal" as the database hostname.
# In dev VPC: resolves to local RDS instance
# In prod VPC: resolves to production RDS instance
#
# Result: MCP server code uses one config value regardless of environment:
# DATABASE_URL = "postgresql://db.mcp.internal:5432/mcp_db"
# Private zone for prod VPC
prod_zone = route53.create_hosted_zone(
Name="mcp.internal",
CallerReference=str(uuid.uuid4()),
HostedZoneConfig={"PrivateZone": True},
VPC={"VPCRegion": "us-east-1", "VPCId": "vpc-prod"},
)
prod_zone_id = prod_zone["HostedZone"]["Id"]
route53.change_resource_record_sets(
HostedZoneId=prod_zone_id,
ChangeBatch={"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "db.mcp.internal",
"Type": "CNAME",
"TTL": 60,
# Production RDS proxy endpoint (for connection pooling)
"ResourceRecords": [{"Value": "mcp-db-prod.proxy-abc123.us-east-1.rds.amazonaws.com"}],
}
}]}
)
# Private zone for dev VPC — same zone name, different VPC
dev_zone = route53.create_hosted_zone(
Name="mcp.internal",
CallerReference=str(uuid.uuid4()),
HostedZoneConfig={"PrivateZone": True},
VPC={"VPCRegion": "us-east-1", "VPCId": "vpc-dev"},
)
dev_zone_id = dev_zone["HostedZone"]["Id"]
route53.change_resource_record_sets(
HostedZoneId=dev_zone_id,
ChangeBatch={"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "db.mcp.internal",
"Type": "CNAME",
"TTL": 60,
# Dev RDS instance — smaller, not production-grade
"ResourceRecords": [{"Value": "mcp-db-dev.cdef5678.us-east-1.rds.amazonaws.com"}],
}
}]}
)
# Service code in both environments:
# import os
# DB_HOST = os.getenv("DB_HOST", "db.mcp.internal") # same in dev and prod
# The VPC resolver handles environment selection transparently
ECS Service Discovery with private hosted zones
ECS Service Discovery uses AWS Cloud Map to automatically register ECS task IPs as DNS records when tasks start, and deregister them when tasks stop. This provides dynamic DNS for MCP microservices running on ECS:
import boto3
servicediscovery = boto3.client("servicediscovery", region_name="us-east-1")
ecs = boto3.client("ecs", region_name="us-east-1")
# Step 1: Create a Cloud Map private DNS namespace
# This creates a Route 53 private hosted zone automatically
namespace = servicediscovery.create_private_dns_namespace(
Name="mcp.internal",
Vpc="vpc-0abc1234567890def",
Description="Service discovery namespace for MCP microservices",
)
operation_id = namespace["OperationId"]
# Poll until namespace creation completes
import time
while True:
op = servicediscovery.get_operation(OperationId=operation_id)
if op["Operation"]["Status"] == "SUCCESS":
namespace_id = op["Operation"]["Targets"]["NAMESPACE"]
break
time.sleep(5)
# Step 2: Create a Cloud Map service for the MCP tool-executor
sd_service = servicediscovery.create_service(
Name="tool-executor",
NamespaceId=namespace_id,
DnsConfig={
"DnsRecords": [{
"Type": "A",
"TTL": 10, # low TTL for fast failover on task replacement
}],
"RoutingPolicy": "MULTIVALUE", # return up to 8 healthy IPs
},
HealthCheckCustomConfig={
"FailureThreshold": 1, # deregister after 1 health check failure
},
)
sd_service_arn = sd_service["Service"]["Arn"]
sd_service_id = sd_service["Service"]["Id"]
# Step 3: Create ECS service with Service Discovery enabled
ecs.create_service(
cluster="mcp-cluster",
serviceName="tool-executor",
taskDefinition="tool-executor:latest",
desiredCount=3,
launchType="FARGATE",
networkConfiguration={
"awsvpcConfiguration": {
"subnets": ["subnet-private-1a", "subnet-private-1b"],
"securityGroups": ["sg-tool-executor"],
"assignPublicIp": "DISABLED",
}
},
# Service Discovery configuration
serviceRegistries=[{
"registryArn": sd_service_arn,
# Port is optional when using A records with awsvpc mode
}],
)
# Result: ECS tasks for "tool-executor" automatically register as:
# tool-executor.mcp.internal → [10.0.1.50, 10.0.1.51, 10.0.1.52]
# When a task stops (scale-in, task failure, rolling deploy), its IP
# is deregistered within seconds. MCP gateway can always resolve
# "tool-executor.mcp.internal" to get the current healthy task IPs.
Cloud Map's MULTIVALUE routing policy returns up to 8 IPs in the DNS response. The MCP gateway connecting to tool-executor.mcp.internal should implement a retry loop that tries each returned IP if one fails — this is the client-side load balancing pattern for Cloud Map service discovery.
VPC resolver configuration requirements
Private hosted zones only resolve when the VPC has the DNS settings enabled that allow the Route 53 resolver to handle queries:
import boto3
ec2 = boto3.client("ec2", region_name="us-east-1")
# Check current DNS settings on the VPC
vpc_response = ec2.describe_vpcs(VpcIds=["vpc-0abc1234567890def"])
vpc = vpc_response["Vpcs"][0]
# enableDnsSupport: allows the VPC resolver (CIDR+2 address) to work
# enableDnsHostnames: assigns DNS hostnames to EC2 instances in the VPC
# Both must be True for private hosted zones to resolve correctly
print(f"enableDnsSupport: {vpc.get('EnableDnsSupport', 'check attributes API')}")
print(f"enableDnsHostnames: {vpc.get('EnableDnsHostnames', 'check attributes API')}")
# Check via attributes API (more reliable):
for attr in ["enableDnsSupport", "enableDnsHostnames"]:
r = ec2.describe_vpc_attribute(VpcId="vpc-0abc1234567890def", Attribute=attr)
enabled = r.get(attr.capitalize(), {}).get("Value", False)
print(f"{attr}: {enabled}")
# Enable both if not already set:
ec2.modify_vpc_attribute(
VpcId="vpc-0abc1234567890def",
EnableDnsSupport={"Value": True},
)
ec2.modify_vpc_attribute(
VpcId="vpc-0abc1234567890def",
EnableDnsHostnames={"Value": True},
)
# The VPC resolver is always at VPC_CIDR+2:
# VPC 10.0.0.0/16 → resolver at 10.0.0.2
# VPC 172.16.0.0/12 → resolver at 172.16.0.2
# This address is reserved by AWS — do not assign it to any resource
# Verify private zone resolution from within the VPC:
# On an EC2 instance or ECS task inside the VPC:
# $ dig tool-executor.mcp.internal
# Should return the private IPs registered via Cloud Map or direct record
Resolver rules for cross-account MCP service meshes
When MCP services are spread across multiple AWS accounts (e.g., a shared tool-executor account and per-team MCP gateway accounts), Route 53 Resolver rules can share private DNS across account boundaries via AWS RAM:
route53resolver = boto3.client("route53resolver", region_name="us-east-1")
# In the shared-services account: create resolver endpoints and rules
# Inbound endpoint: accepts DNS queries from other VPCs
inbound_endpoint = route53resolver.create_resolver_endpoint(
CreatorRequestId=str(uuid.uuid4()),
Name="mcp-shared-inbound",
SecurityGroupIds=["sg-resolver-inbound"],
Direction="INBOUND",
IpAddresses=[
{"SubnetId": "subnet-resolver-1a"},
{"SubnetId": "subnet-resolver-1b"},
],
)
# Resolver rule: forward queries for mcp.internal to shared-services account
resolver_rule = route53resolver.create_resolver_rule(
CreatorRequestId=str(uuid.uuid4()),
Name="mcp-internal-forwarding-rule",
RuleType="FORWARD",
DomainName="mcp.internal",
TargetIps=[
{"Ip": "10.10.0.53", "Port": 53}, # inbound endpoint IPs
{"Ip": "10.10.1.53", "Port": 53},
],
)
# Share the rule via AWS RAM with team accounts
# Team account VPCs associate with the shared rule
# → queries for mcp.internal in team VPCs are forwarded to shared-services DNS
For most single-account MCP deployments, resolver rules are unnecessary — private hosted zones associated with VPCs handle everything. Resolver rules become relevant when MCP services span multiple AWS accounts or when you need to forward queries to on-premises DNS servers for hybrid deployments.
Monitor internal MCP endpoints with AliveMCP
Private hosted zone DNS names are not publicly reachable — but the MCP tools they back can still fail silently. AliveMCP supports private MCP endpoint monitoring via the Team plan: deploy a lightweight probe agent inside your VPC, and AliveMCP's protocol checker validates initialize and tools/list responses at the JSON-RPC layer, even for endpoints that only resolve within your private network.