AWS VPC Networking: Subnets, NAT Gateways & Security Groups Explained
VPC is AWS's networking layer. Misunderstand it, and your infrastructure is either insecure or unreachable. This guide covers production-grade VPC design.
VPC Fundamentals
A VPC is an isolated network within AWS. You define IP address ranges, subnets, routing, and security.
VPC (10.0.0.0/16)
├── Public Subnet (10.0.1.0/24) - EC2, ALB
│ └── Internet Gateway → Internet
├── Private Subnet (10.0.2.0/24) - RDS, ElastiCache
│ └── NAT Gateway → Internet (one-way)
└── Database Subnet (10.0.3.0/24) - RDS cluster
Step 1: Create VPC & Subnets
# main.tf - VPC and subnets
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "main-vpc" }
}
# Public Subnet (AZ-a)
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true # Auto-assign public IPs
tags = { Name = "public-subnet-a" }
}
# Public Subnet (AZ-b) - High Availability
resource "aws_subnet" "public_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "us-east-1b"
map_public_ip_on_launch = true
tags = { Name = "public-subnet-b" }
}
# Private Subnet (AZ-a)
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.11.0/24"
availability_zone = "us-east-1a"
tags = { Name = "private-subnet-a" }
}
# Private Subnet (AZ-b)
resource "aws_subnet" "private_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.12.0/24"
availability_zone = "us-east-1b"
tags = { Name = "private-subnet-b" }
}
Subnet CIDR breakdown:
- VPC: 10.0.0.0/16 = 65,536 IPs
- Public-a: 10.0.1.0/24 = 256 IPs (250 usable)
- Public-b: 10.0.2.0/24 = 256 IPs
- Private-a: 10.0.11.0/24 = 256 IPs
- Private-b: 10.0.12.0/24 = 256 IPs
Step 2: Internet Gateway (Public Access)
# Internet Gateway - allows public traffic
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "main-igw" }
}
# Public Route Table - route to IGW
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0" # All traffic
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "public-rt" }
}
# Associate public subnets with public route table
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_b" {
subnet_id = aws_subnet.public_b.id
route_table_id = aws_route_table.public.id
}
Now public subnets can reach the internet:
Public EC2 → Internet Gateway → AWS edge → Internet (0.0.0.0/0)
Step 3: NAT Gateway (Private Internet Access)
Private instances can't reach internet directly. NAT Gateway provides one-way outbound access.
# Elastic IP for NAT Gateway
resource "aws_eip" "nat_a" {
domain = "vpc"
depends_on = [aws_internet_gateway.main]
tags = { Name = "nat-eip-a" }
}
# NAT Gateway (in public subnet!)
resource "aws_nat_gateway" "nat_a" {
allocation_id = aws_eip.nat_a.id
subnet_id = aws_subnet.public_a.id # Must be public
depends_on = [aws_internet_gateway.main]
tags = { Name = "nat-gw-a" }
}
# Private Route Table
resource "aws_route_table" "private_a" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat_a.id
}
tags = { Name = "private-rt-a" }
}
# Associate private subnets
resource "aws_route_table_association" "private_a" {
subnet_id = aws_subnet.private_a.id
route_table_id = aws_route_table.private_a.id
}
Important: Create one NAT per AZ for redundancy:
# NAT in AZ-b
resource "aws_eip" "nat_b" {
domain = "vpc"
}
resource "aws_nat_gateway" "nat_b" {
allocation_id = aws_eip.nat_b.id
subnet_id = aws_subnet.public_b.id
}
resource "aws_route_table" "private_b" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat_b.id
}
}
resource "aws_route_table_association" "private_b" {
subnet_id = aws_subnet.private_b.id
route_table_id = aws_route_table.private_b.id
}
Now private instances can download updates:
Private EC2 → NAT Gateway (in public subnet) → Internet Gateway → Internet
Incoming traffic → DROPPED (NAT is one-way)
Step 4: Security Groups (Firewalls)
Security groups control inbound/outbound traffic.
# ALB Security Group
resource "aws_security_group" "alb" {
name = "alb-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Allow HTTP from anywhere
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # Allow HTTPS from anywhere
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"] # Allow all outbound
}
tags = { Name = "alb-sg" }
}
# Application Security Group
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
# ALB → App servers
ingress {
from_port = 3000
to_port = 3000
protocol = "tcp"
security_groups = [aws_security_group.alb.id] # Only from ALB
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "app-sg" }
}
# Database Security Group
resource "aws_security_group" "rds" {
name = "rds-sg"
vpc_id = aws_vpc.main.id
# App servers → RDS
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id] # Only from app
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "rds-sg" }
}
Traffic flow:
Internet → ALB (80/443) → App SG (3000) → RDS SG (5432) → Database
Each layer accepts traffic only from the previous layer. Defense in depth.
Network Architecture Diagram
┌─────────────────────── VPC: 10.0.0.0/16 ──────────────────────┐
│ │
│ ┌──────────────────── Public Subnets ──────────────────────┐ │
│ │ │ │
│ │ 10.0.1.0/24 (AZ-a) 10.0.2.0/24 (AZ-b) │ │
│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │
│ │ │ ALB │ │ ALB │ │ │
│ │ │ 10.0.1.10 │ │ 10.0.2.10 │ │ │
│ │ │ alb-sg │ │ alb-sg │ │ │
│ │ └──────────────────────┘ └──────────────────────┘ │ │
│ │ ↓ ↓ │ │
│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │
│ │ │ NAT Gateway │ │ NAT Gateway │ │ │
│ │ │ 10.0.1.5 │ │ 10.0.2.5 │ │ │
│ │ │ EIP: 54.1.1.1 │ │ EIP: 54.1.1.2 │ │ │
│ │ └──────────────────────┘ └──────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ ↓ Internet Gateway (IGW) ↓ │
│ ┌──────────────────── Private Subnets ─────────────────────┐ │
│ │ │ │
│ │ 10.0.11.0/24 (AZ-a) 10.0.12.0/24 (AZ-b) │ │
│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │
│ │ │ App Server 1 │ │ App Server 2 │ │ │
│ │ │ 10.0.11.10 │ │ 10.0.12.10 │ │ │
│ │ │ app-sg │ │ app-sg │ │ │
│ │ └──────────────────────┘ └──────────────────────┘ │ │
│ │ ↓ ↓ │ │
│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │
│ │ │ RDS Primary │ │ RDS Standby │ │ │
│ │ │ 10.0.11.50 │ │ 10.0.12.50 │ │ │
│ │ │ rds-sg │ │ rds-sg │ │ │
│ │ └──────────────────────┘ └──────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
↓
Internet
VPC Flow Logs (Troubleshooting)
# Enable VPC flow logs to CloudWatch
resource "aws_flow_log" "main" {
iam_role_arn = aws_iam_role.flow_logs.arn
log_destination = aws_cloudwatch_log_group.flow_logs.arn
traffic_type = "ALL" # ALL, ACCEPT, REJECT
vpc_id = aws_vpc.main.id
tags = { Name = "vpc-flow-logs" }
}
resource "aws_cloudwatch_log_group" "flow_logs" {
name = "/aws/vpc/flow-logs"
retention_in_days = 7
}
Query logs to debug connectivity:
-- Find rejected traffic
fields srcaddr, dstaddr, dstport, action
| filter action = "REJECT"
| stats count() by dstport
Best Practices
| ✅ Do | ❌ Don't |
|---|---|
| Multi-AZ subnets | Single AZ (single point of failure) |
| One NAT per AZ | One NAT for all (bottleneck) |
| Security groups as firewall | Open all ports (0.0.0.0/0) |
| Small CIDR blocks | Over-sized (hard to expand) |
| VPC Flow Logs | No logging (debug nightmares) |
| Private RDS | Public RDS (security risk) |
Checklist
- VPC CIDR block (e.g., 10.0.0.0/16)
- Public subnets in 2+ AZs
- Private subnets in 2+ AZs
- Internet Gateway for public traffic
- NAT Gateways (one per AZ)
- Route tables for public/private
- Security groups for each tier
- VPC Flow Logs enabled
- NACLs configured (if needed)
- VPC Endpoints for AWS services (optional, advanced)
Conclusion
VPC design is foundational. Get it right once, scale to thousands of instances. Get it wrong, and you'll rewrite it later.
Use multi-AZ design from day one. Security groups are cheap. NAT Gateways cost ~$30/month—well worth the isolation.

