Build a VPC for private workloads
Lay out a VPC where workloads run in private subnets and only load balancers face the internet — subnets across Availability Zones, an internet gateway, a zonal or regional NAT gateway, gateway endpoints, and security groups that reference each other — with the CLI for each step and the fixed limits that shape it.
markdownguidev4QQ43-CMR1stage: buildtrust: officialservice: vpcinterface: clicreate VPCprivate subnetpublic subnetinternet gatewayNAT gatewayregional NAT gatewayavailability-mode regionalroute tablegateway endpoint S3security group referencingnetwork ACLVPC Block Public Accessthree-tier VPC
Put only load balancers (and zonal NAT gateways) in public subnets; put everything else in private subnets in at least two Availability Zones. Give the private subnets a way out: a NAT gateway, or VPC endpoints for the AWS services they call. Chain security groups by reference, so each tier admits only the tier in front of it.
Layout
| Tier | Subnet | Route 0.0.0.0/0 | Security group admits |
|---|---|---|---|
| Load balancer | Public, one per AZ | → internet gateway | 443 from the internet (or from CloudFront's prefix list) |
| App (EC2, Fargate, Lambda) | Private, one per AZ | → NAT gateway | App port from the load balancer's group |
| Database | Private, one per AZ | none needed | DB port from the app's group |
A subnet is "public" only because its route table sends 0.0.0.0/0 to an internet gateway. A resource there still needs a public IP to be reached, and a Lambda function never gets one.
1. VPC, subnets, internet gateway
aws ec2 create-vpc --cidr-block 10.0.0.0/16
aws ec2 create-subnet --vpc-id <vpc> --cidr-block 10.0.0.0/20 --availability-zone <az-a> # public a
aws ec2 create-subnet --vpc-id <vpc> --cidr-block 10.0.16.0/20 --availability-zone <az-b> # public b
aws ec2 create-subnet --vpc-id <vpc> --cidr-block 10.0.128.0/20 --availability-zone <az-a> # private a
aws ec2 create-subnet --vpc-id <vpc> --cidr-block 10.0.144.0/20 --availability-zone <az-b> # private b
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --internet-gateway-id <igw> --vpc-id <vpc>
aws ec2 create-route-table --vpc-id <vpc> # public RT
aws ec2 create-route --route-table-id <public-rt> --destination-cidr-block 0.0.0.0/0 --gateway-id <igw>
aws ec2 associate-route-table --route-table-id <public-rt> --subnet-id <public-a> # and public-b
Only one internet gateway can be attached to a VPC at a time. Size the CIDRs generously: interface endpoints, Lambda network interfaces and Fargate tasks all consume subnet IPs. The VPC count, subnets per VPC, CIDR blocks per VPC and similar are adjustable quotas; read them in Service Quotas.
2. A way out for private subnets
Option A: regional NAT gateway (no public subnet needed)
aws ec2 create-nat-gateway --vpc-id <vpc> --availability-mode regional
aws ec2 create-route --route-table-id <private-rt> --destination-cidr-block 0.0.0.0/0 --nat-gateway-id <nat>
- One NAT ID serves every AZ, so one private route table can serve all private subnets.
- AWS creates its route table, with a route to the internet gateway. The VPC still needs an attached internet gateway.
- It expands into an AZ when a network interface appears there. That can take up to 60 minutes, and traffic is processed cross-zone meanwhile.
- Automatic mode manages the IPs. Manual mode makes you add addresses per AZ (
associate-nat-gateway-address --availability-zone). - It does not do private NAT, and it is not available in constrained AZs.
Option B: zonal NAT gateway per AZ
aws ec2 allocate-address --domain vpc
aws ec2 create-nat-gateway --subnet-id <public-a> --allocation-id <eipalloc> # repeat per AZ
aws ec2 create-route --route-table-id <private-rt-a> --destination-cidr-block 0.0.0.0/0 --nat-gateway-id <nat-a>
A zonal NAT gateway lives in a public subnet, needs an Elastic IP at creation, and serves its own AZ. Use one per AZ and one private route table per AZ, so an AZ outage doesn't cut the others off. Creation errors appear in the NAT gateway's state message (reference/vpc/errors.md).
NAT gateways are billed per hour and per gigabyte processed; see Amazon VPC pricing. Traffic to S3 or DynamoDB through a NAT gateway can usually go through a free gateway endpoint instead (UNVERIFIED "free" in the collected sources).
Option C: no internet at all
Use VPC endpoints for each AWS service the workloads call:
- Gateway endpoints for S3 and DynamoDB, added to the private route tables.
- Interface endpoints for the others (
ecr.api,ecr.dkr,logs,sts,secretsmanager,ssm…), with private DNS on.
Endpoint policies are a second policy layer (patterns/iam-to-vpc-endpoints.md).
3. Security groups by reference
aws ec2 create-security-group --group-name alb --description alb --vpc-id <vpc>
aws ec2 create-security-group --group-name app --description app --vpc-id <vpc>
aws ec2 authorize-security-group-ingress --group-id <alb-sg> --protocol tcp --port 443 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id <app-sg> --protocol tcp --port 8080 --source-group <alb-sg>
- New groups allow all outbound traffic and no inbound traffic.
- Rules are allow-only and stateful, and the groups on a resource are combined.
- A rule referencing a group counts as one rule, however many members it has. A customer-managed prefix list counts as its maximum size.
- The number of rules per group multiplied by the number of groups per network interface cannot exceed 1,000. The defaults for both are adjustable quotas.
4. Network ACLs: leave the default unless you need subnet-level denies
The default NACL allows all traffic. If you add a custom one, remember that it is stateless: allow the ephemeral ports 1024–65535 for return traffic in both directions, or load balancer health checks and NAT responses fail. Each subnet has exactly one NACL. Rules are numbered 1–32766, and the first match wins.
5. Check the account-level guard
If VPC Block Public Access is on in the Region, an internet gateway route alone does not make anything reachable. Add an exclusion for the public subnets, or use Ingress-only mode if only outbound traffic is needed.
Verify
Run Reachability Analyzer from a private instance to the internet gateway on 443, and from the internet gateway to the load balancer (guides/vpc/diagnose-connection-timeout.md).