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 groupInbound ruleOutbound rulePurpose
albSgTCP 443 from 0.0.0.0/0TCP task port to taskSgPublic HTTPS entry point
albSgTCP 80 from 0.0.0.0/0 (redirect only)HTTP → HTTPS redirect
taskSgTCP 3000 from albSgTCP 443 to 0.0.0.0/0MCP server container port from ALB only
endpointSgTCP 443 from taskSgVPC 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.

EndpointTypeService nameWhy needed
ECR APIInterfacecom.amazonaws.REGION.ecr.apiECR GetAuthorizationToken and image metadata calls
ECR DockerInterfacecom.amazonaws.REGION.ecr.dkrDocker layer pulls from ECR
S3Gatewaycom.amazonaws.REGION.s3ECR stores image layers in S3; Gateway endpoint is free
SSMInterfacecom.amazonaws.REGION.ssmParameter Store GetParametersByPath at startup
Secrets ManagerInterfacecom.amazonaws.REGION.secretsmanagerSecret retrieval at startup and rotation polling
CloudWatch LogsInterfacecom.amazonaws.REGION.logsECS 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

SymptomCauseFix
CannotPullContainerError on ECS task startupFargate task in private subnet has no NAT gateway and no ECR VPC endpointAdd 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 endpointenableDnsHostnames or enableDnsSupport is false on the VPCEnable 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 outTask SG inbound rule references wrong port or wrong source SGVerify 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 403Task IAM role missing ssm:GetParametersByPath — endpoint is reachable but IAM still enforcesVPC endpoints handle routing, not authorization; IAM policy on task role must still grant the action
NAT gateway data-processing charges unexpectedly highECR pulls and AWS API calls routing through NAT gateway instead of endpointsVerify 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 downnatGateways: 1 means only one AZ has egressSet natGateways: 2 (one per AZ) for production; accept the additional cost or use VPC endpoints to reduce NAT dependency