AWS VPC Deep Dive: Subnets, Route Tables, and Security Groups

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,

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.

Contents