Introduction#
Every resource in AWS lives in a VPC (Virtual Private Cloud). Understanding VPC components is required for designing secure, multi-tier architectures. Misconfigurations — public subnets for databases, overly permissive security groups — are among the most common AWS security issues.
VPC Components#
1
2
3
4
5
6
7
8
9
10
11
12
VPC (10.0.0.0/16)
├── Availability Zone us-east-1a
│ ├── Public Subnet (10.0.1.0/24) → Internet Gateway
│ ├── Private Subnet (10.0.2.0/24) → NAT Gateway
│ └── DB Subnet (10.0.3.0/24) → No internet access
├── Availability Zone us-east-1b
│ ├── Public Subnet (10.0.4.0/24)
│ ├── Private Subnet (10.0.5.0/24)
│ └── DB Subnet (10.0.6.0/24)
├── Internet Gateway (for public subnet outbound)
├── NAT Gateway (for private subnet outbound to internet)
└── VPC Endpoints (private access to AWS services)
Subnet Design#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Terraform: 3-tier VPC design
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet("10.0.0.0/16", 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true # EC2 instances get public IPs
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet("10.0.0.0/16", 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
}
resource "aws_subnet" "database" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet("10.0.0.0/16", 8, count.index + 20)
availability_zone = data.aws_availability_zones.available.names[count.index]
}
Route Tables#
Each subnet is associated with a route table defining where traffic goes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Public subnet route table: default route to Internet Gateway
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
# Private subnet route table: default route to NAT Gateway
resource "aws_route_table" "private" {
count = 2
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
}
# Database subnets: no default route (no internet access)
resource "aws_route_table" "database" {
vpc_id = aws_vpc.main.id
# Only local VPC routes (10.0.0.0/16 → local) — implicit
}
Security Groups vs NACLs#
Security Groups: stateful firewall at the instance level. Return traffic is automatically allowed.
NACLs (Network ACLs): stateless firewall at the subnet level. Must explicitly allow both inbound and outbound. Rules evaluated in order by number.
For most architectures, security groups are sufficient. NACLs add defense-in-depth.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# Security group: allow HTTPS from internet, block everything else
resource "aws_security_group" "alb" {
name = "alb-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# App security group: only from ALB security group
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id] # source SG, not CIDR
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# DB security group: only from app security group
resource "aws_security_group" "db" {
name = "db-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
}
Referencing security groups as sources (not CIDRs) means the rule automatically applies to any new instance added to the source security group.
VPC Endpoints#
Private connectivity to AWS services without traffic going to the public internet.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# S3 Gateway Endpoint (free)
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = aws_route_table.private[*].id
}
# SSM Interface Endpoint (for private EC2 instances without internet)
resource "aws_vpc_endpoint" "ssm" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.ssm"
vpc_endpoint_type = "Interface"
subnet_ids = aws_subnet.private[*].id
security_group_ids = [aws_security_group.vpc_endpoint.id]
private_dns_enabled = true
}
VPC Peering and Transit Gateway#
1
2
3
4
5
6
7
8
9
10
11
12
13
# Peering: direct connection between two VPCs
resource "aws_vpc_peering_connection" "prod_to_shared" {
vpc_id = aws_vpc.production.id
peer_vpc_id = aws_vpc.shared_services.id
auto_accept = true
}
# Add routes in both VPCs
resource "aws_route" "prod_to_shared" {
route_table_id = aws_route_table.private[0].id
destination_cidr_block = "10.1.0.0/16" # shared services CIDR
vpc_peering_connection_id = aws_vpc_peering_connection.prod_to_shared.id
}
For more than 5-10 VPCs, use AWS Transit Gateway instead of a mesh of peering connections.
Conclusion#
Design VPCs with three tiers: public (ALB, NAT), private (application), and database (isolated). Use security group references instead of CIDRs for application-tier rules. Add VPC endpoints for S3 and SSM to keep traffic off the public internet. Database subnets should have no default route — only local VPC traffic.