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 GroupNetwork ACL
Attaches toAn instance / ENIA whole subnet
StateStateful — replies auto-allowedStateless — must allow both directions
RulesAllow onlyAllow and Deny
Can referenceOther security groupsCIDR 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

Example · bash
# 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/24

When 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.

Example · bash
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.

Example · bash
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/FlowLogsRole

Query 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.

Example · bash
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

  • Be the first to comment on this lesson.