HomeTutorialsSee the CLI build
AWSBeginnerArchitecture + CLI

AWS VPC Explained: Subnets, Route Tables, NAT and a 2-AZ Architecture

Short answer: An Amazon VPC (Virtual Private Cloud) is your own logically isolated network inside an AWS Region. You choose its IP address range, split it into subnets in different Availability Zones, and use route tables, gateways, security groups and network ACLs to control exactly which traffic can enter, leave or move inside it.

Almost everything you build on AWS sits inside a VPC: EC2 instances, RDS databases, load balancers, ECS tasks, Lambda functions that need private resources, and EKS clusters. When something "can't connect", the cause is usually one of the pieces below: a missing route, a security group rule, a NACL that forgot return traffic, or a NAT gateway in the wrong place.

My background is databases, so I think of a VPC like this: the CIDR block is the address space, subnets are partitions, route tables are the rules for where each packet goes next, and security groups are the grants on each object. This guide walks through each piece, then puts them together into a 2-AZ, 3-tier architecture you can build with the AWS CLI.

AWS VPC at a glance#

Last updated:

Component What it does Lives in Cost model (no figures; see the pricing page)
VPC Isolated network with one or more IPv4 CIDR blocks One Region, spans all its AZs No charge for the VPC itself
Subnet A range of the VPC's addresses where you launch resources Exactly one AZ No charge
Route table Decides where traffic from a subnet goes next VPC; associated with subnets No charge
Internet gateway (IGW) Two-way path between the VPC and the internet Attached to one VPC No charge for the gateway; data transfer and public IPv4 addresses are charged
NAT gateway Outbound-only internet (or other-network) access for private subnets Zonal: one AZ. Regional: follows your workloads across AZs Hourly plus per GB processed, plus data transfer
Security group Stateful allow rules on a network interface VPC; attached to ENIs No charge
Network ACL Stateless, numbered allow/deny rules on a subnet VPC; associated with subnets No charge
Gateway endpoint Private route to S3 or DynamoDB in the same Region Route tables you choose No additional charge
Interface endpoint Private IPs (ENIs) for an AWS or partner service, via AWS PrivateLink One ENI per subnet you choose Hourly per AZ plus per GB processed

What is an Amazon VPC?#

A VPC is a virtual network dedicated to your AWS account. It belongs to one Region and spans every Availability Zone (AZ) in that Region. Inside it you create subnets, and each subnet sits entirely in one AZ. That single rule shapes every highly available design on AWS: if you want to survive the loss of an AZ, you need subnets, and copies of your resources, in at least two of them.

Every Region gives your account a default VPC so you can launch an instance without any setup. It uses 172.31.0.0/16, has a /20 default subnet in each AZ, has an internet gateway attached, and its main route table sends 0.0.0.0/0 to that gateway. Instances launched there get public IPv4 addresses by default. That is convenient for a quick test, but every default subnet is public.

A nondefault VPC (one you create) starts much more locked down: no internet gateway, no route to the internet, no automatic public IPs. You add exactly what you need. Production workloads normally use nondefault VPCs, and that is what we build below.

By default an account can create 5 VPCs per Region (an adjustable quota), with up to 200 subnets in each VPC.

CIDR blocks and subnet math#

When you create a VPC you give it an IPv4 CIDR block, such as 10.0.0.0/16. The number after the slash is the prefix length: how many of the 32 bits are fixed. The rest are yours to hand out.

addresses in a block = 2 ^ (32 - prefix length)

/16 -> 2 ^ 16 = 65,536 addresses
/24 -> 2 ^ 8  =    256 addresses
/28 -> 2 ^ 4  =     16 addresses

AWS allows VPC and subnet IPv4 blocks between /16 (65,536 addresses) and /28 (16 addresses). AWS recommends the private (RFC 1918) ranges: 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. A few practical rules:

  • Plan for growth. You can't resize a CIDR block after creation. You can add secondary CIDR blocks later (5 per VPC by default), but a sensible first block saves pain.
  • Don't overlap with networks you may connect to. If the VPC might later be peered with another VPC or connected to an office network, give each one a different range. Overlapping ranges can't be routed to each other.
  • Avoid 172.17.0.0/16. AWS warns that some services, such as AWS Cloud9 and Amazon SageMaker AI, and Docker use this range, which can cause IP conflicts.

Splitting 10.0.0.0/16 into /24 subnets#

A /16 holds 256 /24 blocks: 10.0.0.0/24, 10.0.1.0/24, and so on up to 10.0.255.0/24. Using /24s keeps the math readable, because the third number of the address tells you the subnet. Subnets in the same VPC can't overlap.

The 5 reserved addresses in every subnet#

AWS reserves the first four addresses and the last address of every subnet. For 10.0.0.0/24:

Address Reserved for
10.0.0.0 Network address
10.0.0.1 The VPC router
10.0.0.2 DNS. The Amazon DNS server is at the VPC range's base plus two; AWS also reserves base plus two in every subnet
10.0.0.3 Reserved for future use
10.0.0.255 Network broadcast address. VPCs don't support broadcast, so AWS reserves it

So a /24 has 251 usable addresses, and the smallest subnet, a /28, has 11. Keep that in mind for services that scale out inside your subnets: an Application Load Balancer, for example, needs subnets with at least a /27 mask and at least eight free IP addresses per subnet.

Prefix Total addresses Usable in a subnet
/16 65,536 65,531
/20 4,096 4,091
/24 256 251
/26 64 59
/27 32 27
/28 16 11

If prefix lengths are new to you, the Linux networking basics module shows the same /24 notation on a real Linux machine with ip addr and ip route.

Check a subnet plan with Python#

This script uses only Python's standard ipaddress module. It lays out six /24 subnets (three tiers in two AZs), prints the reserved addresses, and catches the planning mistakes I see most: overlaps, subnets outside the VPC range, sizes outside /16 to /28, and typos with host bits set.

import ipaddress

VPC_CIDR = ipaddress.ip_network("10.0.0.0/16")
AWS_RESERVED_PER_SUBNET = 5          # first four + last address
MIN_PREFIX, MAX_PREFIX = 16, 28      # allowed IPv4 sizes for VPCs and subnets

PLAN = [
    ("public-a", "public", "us-east-1a", "10.0.0.0/24"),
    ("public-b", "public", "us-east-1b", "10.0.1.0/24"),
    ("app-a",    "app",    "us-east-1a", "10.0.10.0/24"),
    ("app-b",    "app",    "us-east-1b", "10.0.11.0/24"),
    ("data-a",   "data",   "us-east-1a", "10.0.20.0/24"),
    ("data-b",   "data",   "us-east-1b", "10.0.21.0/24"),
]


def validate(vpc, subnets):
    """Return a list of problems with a subnet plan (empty list = OK)."""
    problems, nets = [], []
    for name, cidr in subnets:
        try:
            net = ipaddress.ip_network(cidr)  # strict: rejects host bits set
        except ValueError:
            fixed = ipaddress.ip_network(cidr, strict=False)
            problems.append(f"{name}: {cidr} has host bits set; AWS would store it as {fixed}")
            net = fixed
        if not MIN_PREFIX <= net.prefixlen <= MAX_PREFIX:
            problems.append(f"{name}: /{net.prefixlen} is outside the allowed /{MIN_PREFIX}-/{MAX_PREFIX}")
        if not net.subnet_of(vpc):
            problems.append(f"{name}: {net} is not inside the VPC range {vpc}")
        for other_name, other in nets:
            if net.overlaps(other):
                problems.append(f"{name}: {net} overlaps {other_name} ({other})")
        nets.append((name, net))
    return problems

# The full script (download below) also prints the plan table, the reserved
# addresses, one summary CIDR per tier and the next free blocks for a third AZ.

The full file is vpc_subnet_planner.py in the code download. This is the real output, run with Python 3.13.5 on Linux on October 23, 2026:

VPC CIDR: 10.0.0.0/16  ->  65,536 IPv4 addresses
Allowed sizes: /16 (65,536 addresses) to /28 (16 addresses, 11 usable as a subnet)
/24 subnets that fit in 10.0.0.0/16: 256 (10.0.0.0/24 ... 10.0.255.0/24)

Reference plan: 2 AZs x 3 tiers
name      tier    AZ          CIDR          total usable  first usable  last usable
-----------------------------------------------------------------------------------
public-a  public  us-east-1a  10.0.0.0/24     256    251  10.0.0.4      10.0.0.254
public-b  public  us-east-1b  10.0.1.0/24     256    251  10.0.1.4      10.0.1.254
app-a     app     us-east-1a  10.0.10.0/24    256    251  10.0.10.4     10.0.10.254
app-b     app     us-east-1b  10.0.11.0/24    256    251  10.0.11.4     10.0.11.254
data-a    data    us-east-1a  10.0.20.0/24    256    251  10.0.20.4     10.0.20.254
data-b    data    us-east-1b  10.0.21.0/24    256    251  10.0.21.4     10.0.21.254
Addresses allocated to subnets: 1,536 of 65,536 (2.3%); the rest is free for later tiers or AZs

The 5 reserved addresses in public-a (10.0.0.0/24):
  network     10.0.0.0
  vpc_router  10.0.0.1
  dns_plus_2  10.0.0.2
  future_use  10.0.0.3
  broadcast   10.0.0.255

Validate the reference plan:
  OK, no problems

Validate a broken plan (mistakes on purpose):
  public-b: 10.0.0.128/25 overlaps public-a (10.0.0.0/24)
  app-a: 10.1.10.0/24 is not inside the VPC range 10.0.0.0/16
  tiny: /29 is outside the allowed /16-/28
  typo: 10.0.40.5/24 has host bits set; AWS would store it as 10.0.40.0/24

One CIDR per tier (handy for NACL and security group rules):
  public  10.0.0.0/24, 10.0.1.0/24  ->  10.0.0.0/23
  app     10.0.10.0/24, 10.0.11.0/24  ->  10.0.10.0/23
  data    10.0.20.0/24, 10.0.21.0/24  ->  10.0.20.0/23

Next free /24 blocks for a third AZ (us-east-1c):
  10.0.2.0/24   free
  10.0.12.0/24  free
  10.0.22.0/24  free

Two things to notice. First, six /24s use only 2.3% of the /16, which leaves plenty of room for new tiers or a third AZ (10.0.2.0/24, 10.0.12.0/24 and 10.0.22.0/24 are free and keep the numbering pattern). Second, the gap-of-ten numbering lets one CIDR describe a whole tier: 10.0.10.0/23 covers both app subnets, which keeps NACL rules short. If you later add 10.0.12.0/24 for a third AZ, that /23 no longer covers it, and you would widen the rule to 10.0.8.0/21 or add a rule for the new subnet.

Public, private and isolated subnets#

AWS describes subnet types by their routes:

  • Public subnet: its route table has a direct route to an internet gateway. Load balancers that face the internet, NAT gateways and bastion hosts (if you still use them) live here.
  • Private subnet: no direct route to an internet gateway. Resources here need a NAT device to reach the internet, and the internet can't start connections to them.
  • Isolated subnet: no routes to destinations outside the VPC at all, only the local route. Databases often live here.
  • VPN-only subnet: routes to a site-to-site VPN connection through a virtual private gateway, but not to an internet gateway.

A public subnet alone doesn't make an instance reachable. The instance also needs a public IPv4 address (or an Elastic IP), and its security group must allow the traffic. The reverse matters too: an instance in a private subnet can't reach the internet even if it somehow has a public IP address, because its subnet has no route to the internet gateway.

Auto-assign public IPv4 is a per-subnet setting. In the design below it stays off everywhere. The ALB and the NAT gateways get their own public addresses, and nothing else needs one. That also avoids paying for public IPv4 addresses you don't use, because AWS charges for every public IPv4 address by the hour, whether it is in use or idle.

Route tables: how traffic finds its way#

Every subnet is associated with exactly one route table. One route table can serve many subnets. If you don't associate a subnet with a custom route table, it uses the VPC's main route table.

Every route table starts with a local route for the VPC's CIDR block (10.0.0.0/16 to local). It lets every subnet reach every other subnet in the VPC, and you can't delete it. Everything else is a route you add.

When more than one route matches a packet, the most specific route wins (longest prefix match). A route to 10.0.0.0/16 beats 0.0.0.0/0 for a VPC address, and an S3 gateway endpoint's prefix-list route beats 0.0.0.0/0 for S3 traffic in the same Region.

These are the four route tables in the reference design:

Route table Associated subnets Destination Target
rt-public public-a, public-b 10.0.0.0/16 local
0.0.0.0/0 Internet gateway
rt-app-a app-a 10.0.0.0/16 local
0.0.0.0/0 NAT gateway A (in public-a)
S3 prefix list S3 gateway endpoint
rt-app-b app-b 10.0.0.0/16 local
0.0.0.0/0 NAT gateway B (in public-b)
S3 prefix list S3 gateway endpoint
rt-data data-a, data-b 10.0.0.0/16 local

The data tier has no 0.0.0.0/0 route at all, so it is isolated. A managed database such as RDS doesn't need a route to the internet in your VPC to run.

One practical habit: leave the main route table with only the local route, and associate every subnet with an explicit custom route table. Then a new subnet that someone forgets to associate gets no internet path by accident.

Internet gateway (IGW)#

An internet gateway connects a VPC to the internet in both directions. AWS runs it as a horizontally scaled, redundant, highly available component, and it doesn't limit your bandwidth, so there is no "IGW per AZ" to plan. A VPC can have only one internet gateway attached at a time.

For IPv4, the gateway performs one-to-one NAT between an instance's private address and its public address. The instance itself only ever sees its private IP; the public IP is a mapping held by AWS.

Three things must all be true for an instance to be reachable from the internet:

  1. The VPC has an internet gateway attached.
  2. The subnet's route table sends 0.0.0.0/0 (or the relevant range) to that gateway.
  3. The instance has a public IPv4 address or Elastic IP, and its security group (and the subnet's NACL) allows the traffic.

There is no charge for the internet gateway itself; data transfer and public IPv4 addresses are charged.

NAT gateway: outbound internet for private subnets#

App servers in private subnets still need to download OS patches, pull container images or call third-party APIs. A public NAT gateway lets them start connections out to the internet while blocking anything the internet tries to start. Many private addresses share the NAT gateway's public address.

A zonal NAT gateway (the type most tutorials and exam questions describe):

  • is created in a specific public subnet, in one AZ, with an Elastic IP associated at creation;
  • is reached by adding 0.0.0.0/0 to the NAT gateway in the private subnet's route table;
  • sends its own traffic out through the internet gateway, so the public subnet's route table needs the IGW route;
  • has no security groups of its own. Control traffic with the security groups on your instances and with NACLs.

AWS also offers a private NAT gateway, which has no Elastic IP and is used to reach other VPCs or on-premises networks through a transit gateway or virtual private gateway.

Why one NAT gateway per AZ#

A zonal NAT gateway is redundant inside its own AZ, but it lives in that AZ. AWS documents the consequence: if resources in several AZs share one NAT gateway and that NAT gateway's AZ goes down, the resources in the other AZs lose internet access. The resilient pattern is one NAT gateway in each AZ, with each AZ's private route table pointing at the NAT gateway in the same AZ. That is why the reference design has rt-app-a and rt-app-b instead of one shared app route table.

It also avoids sending every AZ's outbound traffic across AZ boundaries, which can add cross-AZ data transfer charges. The trade-off is cost: each NAT gateway is billed for every hour it exists plus every GB it processes. Some teams use a single NAT gateway in development accounts and one per AZ in production. Check the VPC pricing page for current rates in your Region.

The newer option: regional NAT gateways#

AWS now also offers a regional NAT gateway. Instead of creating one NAT gateway per AZ and one route table per AZ, you create a single NAT gateway with availability mode set to regional. It doesn't need a public subnet; it expands automatically into AZs where you have workloads and contracts from AZs that have none. AWS notes it can take up to 60 minutes to expand to a new AZ after a resource first appears there. It is billed hourly for each AZ it is running in, plus data processing. Regional mode supports public connectivity only (no private NAT). I still teach the zonal, one-per-AZ design first, because it shows exactly how routing works and it is what most existing environments use.

# Regional NAT gateway (alternative to steps 4 and 5 of the build below; not executed)
aws ec2 create-nat-gateway --vpc-id "$VPC_ID" --availability-mode regional

Cut NAT traffic with endpoints#

Traffic from private subnets to S3 or DynamoDB doesn't need to go through the NAT gateway. A gateway endpoint (next sections) routes it privately at no extra charge, which also removes that traffic from your NAT processing bill.

Security groups vs network ACLs#

Both filter traffic, but they work at different levels and in different ways.

Security group Network ACL
Applies to A network interface (instance, ALB, RDS, endpoint ENI) A whole subnet
Rules Allow only Allow and deny
Evaluation All rules are evaluated together Rules are evaluated in order, lowest number first; the first match wins
State Stateful: return traffic is allowed automatically Stateless: return traffic must be allowed explicitly
Default New group: no inbound rules, all outbound allowed Default NACL: allows all. A new custom NACL denies everything until you add rules
Can reference Other security groups (for example "from the ALB's group") CIDR blocks only
How many Up to 5 per network interface by default Exactly one per subnet; one NACL can serve many subnets

Stateful vs stateless in one example. An app server sends a request to the database on port 5432. With security groups, you allow inbound 5432 on the database group and the reply is allowed automatically. With a NACL on the database subnet, you must allow inbound 5432 and outbound replies to the client's ephemeral port. The ephemeral range depends on the client: many Linux kernels use 32768 to 61000, while Elastic Load Balancing, NAT gateways and Lambda use 1024 to 65535. AWS suggests allowing 1024 to 65535 in practice.

Reference security groups instead of IP ranges. The cleanest three-tier pattern chains groups together:

Security group Inbound rule Source
alb-sg TCP 443 0.0.0.0/0
app-sg TCP 8080 alb-sg
db-sg TCP 5432 app-sg

Now an app server accepts traffic only from the load balancer, and the database accepts traffic only from app servers, no matter how many instances scale in or out or what IP addresses they get. AWS's own documentation uses this ALB, web server and database chain as its example.

Which should you rely on? AWS recommends security groups as the primary way to control traffic, with network ACLs as an extra, coarse layer, for example to block a known bad range or to keep a data subnet from ever talking to anything except the app tier. A mistaken NACL is a common cause of outages: a deny-all NACL on backend subnets, for example, breaks load balancer health checks. Neither control can filter traffic to the Amazon DNS server at the VPC base plus two or to the instance metadata service.

VPC endpoints: gateway vs interface#

A VPC endpoint lets resources in private subnets reach AWS services without an internet gateway or NAT gateway.

Gateway endpoint Interface endpoint
Services Amazon S3 and DynamoDB only Many AWS services (and S3 and DynamoDB too), plus partner and your own PrivateLink services
How it works Adds a route with the service's prefix list to the route tables you choose Creates an ENI with a private IP in each subnet you choose (one subnet per AZ)
Uses AWS PrivateLink No Yes
Security group None; control access with an endpoint policy Required; allow inbound HTTPS (443) from your resources
DNS Normal public service names, routed privately Private DNS names (needs enableDnsSupport and enableDnsHostnames on the VPC)
Reach Traffic from the VPC to the service in the same Region only Also reachable from connected networks such as on-premises; resolving private DNS from outside the VPC needs Route 53 Resolver endpoints
Cost model No additional charge Hourly per AZ plus per GB processed

For most designs: add an S3 gateway endpoint (and a DynamoDB one if you use DynamoDB) to every private route table on day one, and add interface endpoints only for the services your private workloads call heavily, such as ECR, CloudWatch Logs or Secrets Manager.

Practical architecture: a 2-AZ, 3-tier VPC#

Here is the design that the rest of this post builds. It is the shape behind many production web apps, and you'll meet it again in the AWS Solutions Architect Associate exam.

Walk through one request. A user's HTTPS request reaches the internet gateway, then the ALB, which has a node in each public subnet. The ALB forwards it on port 8080 to an app server in either AZ; app-sg allows that only because the source is alb-sg. The app server queries the database on port 5432, allowed only because the source is app-sg. When the app server needs an OS update, rt-app-a sends that traffic to NAT gateway A in the same AZ; when it writes to S3, the gateway endpoint's route sends it to S3 privately. If AZ A fails, the ALB stops sending traffic there, AZ B keeps its own NAT gateway, and an RDS Multi-AZ deployment fails over to the standby.

The full plan:

Subnet AZ CIDR Route table Holds
public-a AZ A 10.0.0.0/24 rt-public ALB node, NAT gateway A
public-b AZ B 10.0.1.0/24 rt-public ALB node, NAT gateway B
app-a AZ A 10.0.10.0/24 rt-app-a App servers or containers
app-b AZ B 10.0.11.0/24 rt-app-b App servers or containers
data-a AZ A 10.0.20.0/24 rt-data RDS primary (or standby)
data-b AZ B 10.0.21.0/24 rt-data RDS standby (or primary)

Why these choices:

  • Two AZs per tier. An Application Load Balancer needs subnets in at least two AZs, and an RDS DB subnet group should have subnets in at least two AZs.
  • The ALB in public subnets, servers in private ones. Only the load balancer is exposed. For choosing the load balancer type, see ALB vs NLB.
  • Data subnets isolated. No route to the internet, and only app-sg can reach port 5432. If you're deciding what goes in that tier, DynamoDB vs RDS compares the options; for files and objects, see S3 vs EBS vs EFS.
  • A NACL on the data subnets only, as a guardrail on top of the security groups.

Build it with the AWS CLI (not executed)#

The full scripts are vpc_build_cli.sh and vpc_cleanup_cli.sh in the code download. Each step saves the new resource ID to vpc-lab-ids.env, so cleanup knows what to delete. The key commands, step by step:

1. Create the VPC and turn on DNS hostnames. DNS hostnames are off by default in a nondefault VPC, and interface endpoints with private DNS need them.

export AWS_REGION=us-east-1
tags() { echo "ResourceType=$1,Tags=[{Key=Name,Value=$2},{Key=Project,Value=vpc-lab}]"; }

VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --tag-specifications "$(tags vpc vpc-lab)" \
  --query Vpc.VpcId --output text)
aws ec2 wait vpc-available --vpc-ids "$VPC_ID"
aws ec2 modify-vpc-attribute --vpc-id "$VPC_ID" --enable-dns-support '{"Value":true}'
aws ec2 modify-vpc-attribute --vpc-id "$VPC_ID" --enable-dns-hostnames '{"Value":true}'

2. Create six subnets, each pinned to one AZ.

read -r AZ_A AZ_B < <(aws ec2 describe-availability-zones \
  --filters Name=state,Values=available Name=zone-type,Values=availability-zone \
  --query 'AvailabilityZones[0:2].ZoneName' --output text)

subnet() {  # name cidr az
  aws ec2 create-subnet --vpc-id "$VPC_ID" --cidr-block "$2" --availability-zone "$3" \
    --tag-specifications "$(tags subnet "$1")" \
    --query Subnet.SubnetId --output text
}
PUB_A=$(subnet public-a 10.0.0.0/24  "$AZ_A")
PUB_B=$(subnet public-b 10.0.1.0/24  "$AZ_B")
APP_A=$(subnet app-a    10.0.10.0/24 "$AZ_A")
APP_B=$(subnet app-b    10.0.11.0/24 "$AZ_B")
DB_A=$(subnet  data-a   10.0.20.0/24 "$AZ_A")
DB_B=$(subnet  data-b   10.0.21.0/24 "$AZ_B")

3. Attach an internet gateway and make the public subnets public. This route is the only thing that makes them "public".

IGW_ID=$(aws ec2 create-internet-gateway \
  --tag-specifications "$(tags internet-gateway igw-vpc-lab)" \
  --query InternetGateway.InternetGatewayId --output text)
aws ec2 attach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID"

RT_PUB=$(aws ec2 create-route-table --vpc-id "$VPC_ID" \
  --tag-specifications "$(tags route-table rt-public)" \
  --query RouteTable.RouteTableId --output text)
aws ec2 create-route --route-table-id "$RT_PUB" \
  --destination-cidr-block 0.0.0.0/0 --gateway-id "$IGW_ID"
aws ec2 associate-route-table --route-table-id "$RT_PUB" --subnet-id "$PUB_A"
aws ec2 associate-route-table --route-table-id "$RT_PUB" --subnet-id "$PUB_B"

4. Create one NAT gateway per AZ, in the public subnets. Billing starts here.

EIP_A=$(aws ec2 allocate-address --domain vpc --query AllocationId --output text)
NAT_A=$(aws ec2 create-nat-gateway --subnet-id "$PUB_A" --allocation-id "$EIP_A" \
  --query NatGateway.NatGatewayId --output text)
# ...the same for AZ B with PUB_B -> EIP_B, NAT_B
aws ec2 wait nat-gateway-available --nat-gateway-ids "$NAT_A" "$NAT_B"

5. Give each app subnet its own route table that points at the NAT gateway in the same AZ.

RT_APP_A=$(aws ec2 create-route-table --vpc-id "$VPC_ID" \
  --tag-specifications "$(tags route-table rt-app-a)" \
  --query RouteTable.RouteTableId --output text)
aws ec2 create-route --route-table-id "$RT_APP_A" \
  --destination-cidr-block 0.0.0.0/0 --nat-gateway-id "$NAT_A"
aws ec2 associate-route-table --route-table-id "$RT_APP_A" --subnet-id "$APP_A"
# ...the same for AZ B: RT_APP_B -> NAT_B, associated with APP_B

6. Give the data subnets a route table with only the local route. Creating the table and associating it is enough; there is nothing to add.

RT_DB=$(aws ec2 create-route-table --vpc-id "$VPC_ID" \
  --tag-specifications "$(tags route-table rt-data)" \
  --query RouteTable.RouteTableId --output text)
aws ec2 associate-route-table --route-table-id "$RT_DB" --subnet-id "$DB_A"
aws ec2 associate-route-table --route-table-id "$RT_DB" --subnet-id "$DB_B"

7. Add an S3 gateway endpoint to the app route tables.

aws ec2 create-vpc-endpoint --vpc-id "$VPC_ID" \
  --vpc-endpoint-type Gateway --service-name "com.amazonaws.${AWS_REGION}.s3" \
  --route-table-ids "$RT_APP_A" "$RT_APP_B"

8. Chain the security groups.

SG_ALB=$(aws ec2 create-security-group --vpc-id "$VPC_ID" --group-name alb-sg \
  --description "ALB: HTTPS from the internet" --query GroupId --output text)
SG_APP=$(aws ec2 create-security-group --vpc-id "$VPC_ID" --group-name app-sg \
  --description "App tier: 8080 from the ALB only" --query GroupId --output text)
SG_DB=$(aws ec2 create-security-group --vpc-id "$VPC_ID" --group-name db-sg \
  --description "Data tier: PostgreSQL from the app tier only" --query GroupId --output text)

aws ec2 authorize-security-group-ingress --group-id "$SG_ALB" \
  --protocol tcp --port 443 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id "$SG_APP" \
  --protocol tcp --port 8080 --source-group "$SG_ALB"
aws ec2 authorize-security-group-ingress --group-id "$SG_DB" \
  --protocol tcp --port 5432 --source-group "$SG_APP"

9. Optional: a NACL guardrail on the data subnets. Because NACLs are stateless, rule 100 outbound allows the replies to the app tier's ephemeral ports. Rule 110 allows traffic between the two data subnets. Everything else falls through to the final * rule, which denies it.

NACL_DB=$(aws ec2 create-network-acl --vpc-id "$VPC_ID" \
  --query NetworkAcl.NetworkAclId --output text)
aws ec2 create-network-acl-entry --network-acl-id "$NACL_DB" --ingress \
  --rule-number 100 --protocol tcp --port-range From=5432,To=5432 \
  --cidr-block 10.0.10.0/23 --rule-action allow
aws ec2 create-network-acl-entry --network-acl-id "$NACL_DB" --egress \
  --rule-number 100 --protocol tcp --port-range From=1024,To=65535 \
  --cidr-block 10.0.10.0/23 --rule-action allow
aws ec2 create-network-acl-entry --network-acl-id "$NACL_DB" --ingress \
  --rule-number 110 --protocol=-1 --cidr-block 10.0.20.0/23 --rule-action allow
aws ec2 create-network-acl-entry --network-acl-id "$NACL_DB" --egress \
  --rule-number 110 --protocol=-1 --cidr-block 10.0.20.0/23 --rule-action allow
# Then swap each data subnet from the default NACL to this one with
# describe-network-acls + replace-network-acl-association (see the full script).

10. Check the routes.

aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC_ID" \
  --query 'RouteTables[].{Name:Tags[?Key==`Name`]|[0].Value,Routes:Routes[].[DestinationCidrBlock||DestinationPrefixListId,GatewayId||NatGatewayId]}' \
  --output json

You should see four named route tables plus the main one: rt-public with an igw- target, rt-app-a and rt-app-b each with their own nat- target and a pl- prefix-list route to a vpce- target, and rt-data with only local.

Clean up#

Delete in dependency order: endpoint and NAT gateways first, then the Elastic IPs (which are charged while allocated), security groups (db-sg before app-sg before alb-sg, because of the references), subnets, route tables, the NACL, the internet gateway and finally the VPC.

aws ec2 delete-vpc-endpoints --vpc-endpoint-ids "$VPCE_S3"
aws ec2 delete-nat-gateway --nat-gateway-id "$NAT_A"
aws ec2 delete-nat-gateway --nat-gateway-id "$NAT_B"
aws ec2 wait nat-gateway-deleted --nat-gateway-ids "$NAT_A" "$NAT_B"
aws ec2 release-address --allocation-id "$EIP_A"
aws ec2 release-address --allocation-id "$EIP_B"
aws ec2 delete-security-group --group-id "$SG_DB"
aws ec2 delete-security-group --group-id "$SG_APP"
aws ec2 delete-security-group --group-id "$SG_ALB"
for s in "$PUB_A" "$PUB_B" "$APP_A" "$APP_B" "$DB_A" "$DB_B"; do
  aws ec2 delete-subnet --subnet-id "$s"
done
for rt in "$RT_PUB" "$RT_APP_A" "$RT_APP_B" "$RT_DB"; do
  aws ec2 delete-route-table --route-table-id "$rt"
done
aws ec2 delete-network-acl --network-acl-id "$NACL_DB"
aws ec2 detach-internet-gateway --internet-gateway-id "$IGW_ID" --vpc-id "$VPC_ID"
aws ec2 delete-internet-gateway --internet-gateway-id "$IGW_ID"
aws ec2 delete-vpc --vpc-id "$VPC_ID"

Afterwards, check the VPC console for leftover NAT gateways and Elastic IPs in the Region. Those are the two resources that keep costing money if a step fails halfway.

Common mistakes with AWS VPCs#

  1. Picking a CIDR block that's too small or overlaps another network. You can't resize it later, and overlapping ranges can't be peered or routed to each other.
  2. Forgetting the 5 reserved addresses. A /28 has 11 usable IPs, not 16, and services that scale out (load balancers, containers, Lambda ENIs) need free addresses.
  3. Calling a subnet "private" because of its name. Only the route table decides. Check for a 0.0.0.0/0 route to an igw- target.
  4. Putting the NAT gateway in a private subnet. A public NAT gateway must sit in a public subnet whose route table points at the internet gateway; otherwise nothing gets out.
  5. One NAT gateway for every AZ, in production. If its AZ fails, the other AZs lose outbound internet access too. Use one per AZ with per-AZ route tables, or a regional NAT gateway.
  6. A NACL without return traffic. NACLs are stateless; allow the ephemeral port range for replies, or connections hang.
  7. Opening databases to a CIDR range instead of a security group. Reference app-sg as the source so only the app tier can connect.
  8. Paying NAT processing for S3 and DynamoDB traffic. Add gateway endpoints to private route tables.
  9. Interface endpoint with private DNS, but DNS hostnames turned off. Private DNS needs both enableDnsSupport and enableDnsHostnames.
  10. Leaving the lab running. NAT gateways and Elastic IPs bill by the hour. Run the cleanup.

Frequently asked questions#

What is an AWS VPC in simple terms?#

An AWS VPC is your own private network inside one AWS Region. You choose its IP address range, divide it into subnets in different Availability Zones, and decide with route tables, gateways, security groups and network ACLs which traffic can reach your resources and where they can send traffic.

What is the difference between a public and a private subnet?#

A public subnet has a route table entry that sends internet traffic (0.0.0.0/0) to an internet gateway. A private subnet has no such route, so the internet can't reach it directly; resources there use a NAT gateway for outbound internet access. The name you give a subnet doesn't matter, only its routes.

Why does AWS reserve 5 IP addresses in each subnet?#

AWS keeps the first four addresses and the last one: the network address, the VPC router, the DNS server address, one reserved for future use, and the broadcast address, which VPCs don't support. That is why a /24 subnet has 251 usable addresses instead of 256.

Do I need a NAT gateway in every Availability Zone?#

For a resilient production design with zonal NAT gateways, yes. A zonal NAT gateway lives in one AZ, so if that AZ fails, private subnets in other AZs that use it lose internet access. Put one in each AZ and route each AZ to its own, or use a regional NAT gateway, which expands across AZs automatically.

What is the difference between a security group and a network ACL?#

A security group is a stateful set of allow rules attached to a network interface, and return traffic is allowed automatically. A network ACL is a stateless, numbered list of allow and deny rules attached to a subnet, evaluated in order, so you must allow return traffic yourself. AWS recommends security groups as the main control.

What is the difference between a gateway endpoint and an interface endpoint?#

A gateway endpoint adds a route for Amazon S3 or DynamoDB to your route tables and has no additional charge. An interface endpoint uses AWS PrivateLink to place a network interface with a private IP in your subnets, works with many AWS services, needs a security group, and is billed hourly and per GB processed.

Interview questions on AWS VPC#

  1. An EC2 instance in a public subnet can't be reached from the internet. What do you check? That the VPC has an internet gateway attached, the subnet's route table sends 0.0.0.0/0 to it, the instance has a public IPv4 address or Elastic IP, the security group allows the port from the client, and the subnet's NACL allows both the inbound port and the outbound ephemeral ports.
  2. How many usable addresses are in a /26 subnet on AWS? 64 minus 5 reserved, so 59.
  3. Your app servers in two AZs share one NAT gateway in AZ A. What is the risk, and how do you fix it? An AZ A outage cuts outbound internet for AZ B too, and AZ B traffic crosses AZs. Create a NAT gateway in each AZ and give each AZ's private subnets a route table pointing at the local NAT gateway, or use a regional NAT gateway.
  4. Security groups or NACLs: which is stateful? Security groups are stateful; NACLs are stateless.
  5. How do you let only the app tier reach the database? Allow the database port in the database security group with the app tier's security group as the source, not a CIDR range.
  6. How can private instances read from S3 without a NAT gateway? Add an S3 gateway endpoint to their route tables. The prefix-list route is more specific than 0.0.0.0/0, so S3 traffic in the same Region uses the endpoint.
  7. Can you change a VPC's CIDR block after creation? You can't resize it, but you can add secondary CIDR blocks.
  8. Why should production subnets not rely on the main route table? Any new subnet without an explicit association uses the main route table, so if it has an internet route, new subnets become public by accident.
  9. A NACL allows inbound 443, but HTTPS connections time out. Why? NACLs are stateless; the outbound rule must allow replies to the client's ephemeral ports (AWS suggests 1024 to 65535).
  10. Where does a public NAT gateway go, and what does it need? In a public subnet, with an Elastic IP, and with the public subnet's route table pointing at the internet gateway.

Practice exercises#

  1. Subnet math by hand. For 10.0.0.0/16, write the CIDR blocks of the 50th and 51st /24 subnets, then the first and last usable address of each. Check your answers with Python's ipaddress module.
  2. Add a third AZ. Extend the planner's PLAN with public-c, app-c and data-c using the free blocks it printed. Then work out which NACL rule in step 9 breaks and what CIDR (or extra rule) fixes it.
  3. Make a subnet private on purpose. In the reference design, describe the exact route table change that would turn public-b into a private subnet, and what would then stop working.
  4. Write a NACL for the app tier. Allow inbound 8080 from the public subnets (10.0.0.0/23), outbound 5432 to the data subnets, outbound 443 to the internet for updates, and the return traffic each of those needs. List the rules with numbers.
  5. Price the design yourself. Using the VPC pricing page for your Region, estimate a month of two zonal NAT gateways versus one regional NAT gateway running in two AZs, and list which charges (hours, GB processed, public IPv4, data transfer) apply to each.

Where to go next#

This VPC is the base for the hands-on AWS posts that follow in this series, including Auto Scaling behind a load balancer, containers on ECS with Fargate, EC2 troubleshooting and deploying a RAG app on AWS. Until those are out, put a load balancer in front of the app tier with ALB vs NLB, choose the data tier with DynamoDB vs RDS, and browse every AWS guide in the AWS tutorials hub. If Linux networking commands are still new, the Linux from Scratch course covers them from the start.

Sources#

All checked on October 23, 2026.

Continue

Learn AWS networking with guided practice. VPC design, subnets, routing and security groups are core topics in my AWS Solutions Architect Associate training, with live labs in small batches. For all AWS courses see AWS training, and if your team needs a VPC designed or reviewed, see AWS consulting. If you're in the United States, see online AWS and AI/ML training in the USA.

AWS Solutions Architect training

← All AWS tutorials · All tutorials

Newsletter

Liked this? Get the next tutorial by email

New tutorials, labs and cheat sheets, plus AWS batch dates and Oracle tips if you want them. No spam, unsubscribe anytime.

I’m interested in

Double opt-in: you’ll get a confirmation email first. Privacy policy

WhatsApp