VPC Design: Subnets, Security Groups vs NACLs
Put public-facing pieces in public subnets and everything valuable in private ones, and know exactly when to reach for a stateful security group versus a stateless NACL.
A well-designed VPC is layered like an onion: the internet touches as little as possible, and your databases sit as far from it as you can put them.
The classic layout
- Public subnets — only the load balancer and NAT gateway live here. They have a route to an Internet Gateway.
- Private app subnets — your EC2/ECS/Lambda compute. No direct inbound from the internet; outbound goes through the NAT.
- Private data subnets — RDS, ElastiCache. Reachable only from the app tier.
Spread each tier across at least two Availability Zones so a single AZ outage does not take you down.
Security groups vs NACLs — the part everyone mixes up
| Security Group | Network ACL | |
|---|---|---|
| Attaches to | An instance / ENI | A whole subnet |
| State | Stateful — replies auto-allowed | Stateless — must allow both directions |
| Rules | Allow only | Allow and Deny |
| Can reference | Other security groups | CIDR ranges only |
Use security groups as your everyday firewall — and reference one SG from another ("the DB SG allows the app SG on 5432") instead of hard-coding IPs. Fall back to NACLs for coarse, subnet-wide deny rules, like blocking a bad CIDR, which security groups cannot express.
Example
# App SG: allow HTTPS from the load balancer's SG only
aws ec2 authorize-security-group-ingress \
--group-id sg-app0000000000000 \
--protocol tcp --port 443 \
--source-group sg-alb0000000000000
# DB SG: allow Postgres ONLY from the app SG (no CIDRs, no guessing)
aws ec2 authorize-security-group-ingress \
--group-id sg-db00000000000000 \
--protocol tcp --port 5432 \
--source-group sg-app0000000000000
# NACL: coarse subnet-wide DENY of a known-bad range (SGs can't deny)
aws ec2 create-network-acl-entry \
--network-acl-id acl-0123456789abcdef0 \
--rule-number 90 --protocol -1 \
--rule-action deny --egress false \
--cidr-block 198.51.100.0/24When to use it
- A three-tier app places ALBs in public subnets, EC2 app servers in private subnets, and RDS in isolated subnets with no route to the internet.
- A team uses security groups chained from ALB to app to DB instead of NACLs for fine-grained, stateful control that follows instances across AZs.
- A company uses VPC Flow Logs to debug a connectivity problem between a private EC2 instance and a third-party API by examining rejected packets.
More examples
Create Three-Tier Subnet Layout
Creates three subnets tagged by tier, establishing the public-private-isolated separation that is core to secure VPC design.
VPC=vpc-0abc123
# Public subnet for load balancers
aws ec2 create-subnet --vpc-id $VPC --cidr-block 10.0.1.0/24 --availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Tier,Value=public}]'
# Private subnet for app servers
aws ec2 create-subnet --vpc-id $VPC --cidr-block 10.0.10.0/24 --availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Tier,Value=private}]'
# Isolated subnet for databases
aws ec2 create-subnet --vpc-id $VPC --cidr-block 10.0.20.0/24 --availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Tier,Value=isolated}]'Enable VPC Flow Logs to CloudWatch
Enables VPC Flow Logs for all accepted and rejected traffic, essential for debugging connectivity and security auditing.
aws ec2 create-flow-logs \
--resource-type VPC \
--resource-ids vpc-0abc123 \
--traffic-type ALL \
--log-destination-type cloud-watch-logs \
--log-group-name /aws/vpc/flowlogs \
--deliver-logs-permission-arn arn:aws:iam::123456789012:role/FlowLogsRoleQuery Flow Logs for Rejected Traffic
Queries VPC Flow Logs for the 20 most recent REJECT events, helping pinpoint which security group or NACL is blocking traffic.
aws logs start-query \
--log-group-name /aws/vpc/flowlogs \
--start-time $(date -d '1 hour ago' +%s) \
--end-time $(date +%s) \
--query-string 'fields @timestamp, srcAddr, dstAddr, dstPort, action | filter action = "REJECT" | sort @timestamp desc | limit 20'
Discussion