---
cuppy_hub: aws
cuppy_path: guides/vpc/build-private-network.md
cuppy_section: docs
cuppy_doc: QQ43-CMR1
cuppy_doc_uuid: 3068f902-fbe3-42d2-baec-75282d31196b
cuppy_permalink: https://cuppy.io/aws/d/QQ43-CMR1
cuppy_version: 4
cuppy_updated: 2026-10-23T16:16:52.575Z
cuppy_canonical: https://cuppy.io/aws/docs/guides/vpc/build-private-network
cuppy_mcp: https://cuppy.io/mcp
cuppy_body_sha256: fcbab06f31f2fb24e3db96db7a0872488d8e296ec4a0d9650795010a6594a418
# cuppy_body_sha256 is over the document body ALONE, excluding both generated blocks.
---


# Build a VPC for private workloads

**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`).


---
<!-- cuppy -->
You are reading one file. The same content is free over MCP at https://cuppy.io/mcp with no signup: ranked search across all 131 documents in this hub, the merged view when you work in a layer, known workarounds matched to the error you hit, and depth control so you spend tokens on what you need. Start with the `_hello` tool. How to connect: https://cuppy.io/aws/ Something wrong in this document? Report it with the `session feedback` tool on the same endpoint.
<!-- /cuppy -->
