Guide · AWS Networking
MCP Server VPC — private subnets, security groups, NAT gateway, VPC endpoints
A well-designed VPC is the security and cost foundation for any MCP server deployed on ECS Fargate. Three mistakes hit almost every first deployment: placing ECS tasks in a public subnet with a public IP (works, but exposes the task directly to the internet — the ALB should be the only public-facing entry point; tasks belong in private subnets), missing NAT gateway for ECR pulls (a Fargate task in a private subnet with no NAT gateway and no VPC endpoint for ECR will fail to pull its container image during task startup with CannotPullContainerError — the task has no route to the public ECR endpoint), and forgetting enableDnsHostnames: true on the VPC (Interface VPC endpoints require both DNS resolution and DNS hostnames to be enabled; without enableDnsHostnames, the endpoint's private DNS override does not apply, and SDK calls still route through the public endpoint, bypassing the endpoint entirely).
TL;DR
Place ALB in public subnets and ECS tasks in private subnets. Create one NAT gateway per AZ in a public subnet (or a single NAT gateway for cost savings with reduced HA). Add Interface VPC endpoints for ecr.api, ecr.dkr, ssm, secretsmanager, and logs, plus a Gateway endpoint for S3 (ECR layers are stored in S3). Enable enableDnsHostnames and enableDnsSupport on the VPC or endpoint DNS overrides won't work. Security groups: ALB SG accepts 443 from 0.0.0.0/0; task SG accepts traffic only from the ALB SG.
Subnet layout and routing
The standard MCP server VPC layout uses two tiers: public subnets for the ALB and NAT gateways, private subnets for ECS tasks (and optionally RDS/ElastiCache). Traffic flows inbound through the ALB and outbound through the NAT gateway or VPC endpoints.
// CDK: VPC with public and private subnets across 2 AZs
import * as ec2 from "aws-cdk-lib/aws-ec2";
const vpc = new ec2.Vpc(this, "McpVpc", {
maxAzs: 2,
natGateways: 1, // single NAT gateway: lower cost, less HA
// natGateways: 2, // one per AZ: true HA but $0.045/hr per gateway
subnetConfiguration: [
{
name: "Public",
subnetType: ec2.SubnetType.PUBLIC,
cidrMask: 24,
},
{
name: "Private",
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidrMask: 24,
},
],
enableDnsHostnames: true, // required for Interface VPC endpoint private DNS
enableDnsSupport: true, // required for VPC DNS resolver
});
// ECS cluster uses private subnets
const cluster = new ecs.Cluster(this, "McpCluster", { vpc });
// ECS service network configuration — private subnets only
const service = new ecs.FargateService(this, "McpService", {
cluster,
taskDefinition,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
assignPublicIp: false, // tasks in private subnets must not have public IPs
});
NAT gateway cost: a single NAT gateway costs $0.045/hr (~$32/month) plus $0.045/GB of data processed. If your ECS tasks make frequent large AWS API calls or pull images often, VPC endpoints can eliminate the majority of that data-processing charge.
Security group chain
The security group design for an MCP server follows a chain: the ALB accepts public HTTPS traffic, and the ECS task accepts traffic only from the ALB security group. No other inbound traffic reaches the task.
| Security group | Inbound rule | Outbound rule | Purpose |
|---|---|---|---|
albSg | TCP 443 from 0.0.0.0/0 | TCP task port to taskSg | Public HTTPS entry point |
albSg | TCP 80 from 0.0.0.0/0 (redirect only) | — | HTTP → HTTPS redirect |
taskSg | TCP 3000 from albSg | TCP 443 to 0.0.0.0/0 | MCP server container port from ALB only |
endpointSg | TCP 443 from taskSg | — | VPC Interface endpoints accept HTTPS from tasks |
// CDK: security group chain
const albSg = new ec2.SecurityGroup(this, "AlbSg", {
vpc,
description: "ALB: public HTTPS",
allowAllOutbound: false,
});
albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "HTTPS from internet");
albSg.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), "HTTP redirect");
const taskSg = new ec2.SecurityGroup(this, "TaskSg", {
vpc,
description: "ECS task: accepts traffic from ALB only",
allowAllOutbound: true, // tasks need outbound for ECR pull, SSM, Secrets Manager
});
taskSg.addIngressRule(albSg, ec2.Port.tcp(3000), "ALB to MCP container");
// Allow ALB to reach tasks
albSg.addEgressRule(taskSg, ec2.Port.tcp(3000), "ALB to MCP task");
VPC endpoints: eliminate NAT gateway charges for AWS API calls
Every AWS API call from a task in a private subnet — ECR image pull, SSM parameter fetch, Secrets Manager read, CloudWatch log write — routes through the NAT gateway and incurs $0.045/GB data-processing charge. Interface VPC endpoints create a private link to the service endpoint directly inside the VPC, bypassing the NAT gateway and eliminating the data-processing charge for those calls.
| Endpoint | Type | Service name | Why needed |
|---|---|---|---|
| ECR API | Interface | com.amazonaws.REGION.ecr.api | ECR GetAuthorizationToken and image metadata calls |
| ECR Docker | Interface | com.amazonaws.REGION.ecr.dkr | Docker layer pulls from ECR |
| S3 | Gateway | com.amazonaws.REGION.s3 | ECR stores image layers in S3; Gateway endpoint is free |
| SSM | Interface | com.amazonaws.REGION.ssm | Parameter Store GetParametersByPath at startup |
| Secrets Manager | Interface | com.amazonaws.REGION.secretsmanager | Secret retrieval at startup and rotation polling |
| CloudWatch Logs | Interface | com.amazonaws.REGION.logs | ECS awslogs driver log delivery |
// CDK: VPC endpoints for ECR, SSM, Secrets Manager, CloudWatch Logs
const endpointSg = new ec2.SecurityGroup(this, "EndpointSg", {
vpc,
description: "VPC Interface endpoints",
allowAllOutbound: false,
});
endpointSg.addIngressRule(taskSg, ec2.Port.tcp(443), "tasks to endpoints");
// S3 Gateway endpoint (free — no hourly charge)
vpc.addGatewayEndpoint("S3Endpoint", {
service: ec2.GatewayVpcEndpointAwsService.S3,
});
// Interface endpoints ($0.01/hr each + $0.01/GB)
for (const [id, service] of Object.entries({
EcrApi: ec2.InterfaceVpcEndpointAwsService.ECR,
EcrDkr: ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
Ssm: ec2.InterfaceVpcEndpointAwsService.SSM,
SecretsManager: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
Logs: ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
})) {
new ec2.InterfaceVpcEndpoint(this, id + "Endpoint", {
vpc,
service,
subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [endpointSg],
privateDnsEnabled: true, // overrides public DNS — SDK calls route to endpoint automatically
});
}
Cost note: each Interface endpoint costs $0.01/hr (~$7.20/month) plus $0.01/GB. Five Interface endpoints cost ~$36/month but can save more than that if your tasks make frequent AWS API calls or pull images on every deploy. Profile before adding all five — start with ECR and SSM, which typically carry the most traffic.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
CannotPullContainerError on ECS task startup | Fargate task in private subnet has no NAT gateway and no ECR VPC endpoint | Add NAT gateway to a public subnet with a route from private subnet, or add ecr.api, ecr.dkr, and S3 Gateway endpoints |
| VPC endpoint exists but SDK still routes through public endpoint | enableDnsHostnames or enableDnsSupport is false on the VPC | Enable both VPC DNS options; delete and recreate endpoint after enabling (existing endpoints may not pick up the change) |
| Task SG accepts connection but ALB health check times out | Task SG inbound rule references wrong port or wrong source SG | Verify task SG inbound rule allows the container port from the ALB SG (not 0.0.0.0/0) |
| ECS task can reach endpoint SG but SSM call returns 403 | Task IAM role missing ssm:GetParametersByPath — endpoint is reachable but IAM still enforces | VPC endpoints handle routing, not authorization; IAM policy on task role must still grant the action |
| NAT gateway data-processing charges unexpectedly high | ECR pulls and AWS API calls routing through NAT gateway instead of endpoints | Verify privateDnsEnabled: true on each Interface endpoint and confirm S3 Gateway endpoint routes cover the task subnet |
| Second AZ tasks fail when single NAT gateway AZ goes down | natGateways: 1 means only one AZ has egress | Set natGateways: 2 (one per AZ) for production; accept the additional cost or use VPC endpoints to reduce NAT dependency |