Skip to main content

Network Layer: VPC, Security Groups, and NACLs

The network layer is your first line of defense. A well-designed VPC limits what traffic can flow between resources, reducing the blast radius of any compromise. Even if an attacker obtains valid credentials, network controls determine what they can reach.

VPC as the Network Boundary​

A VPC is an isolated virtual network within AWS. Every resource you launch lives inside a VPC, and the VPC defines the network topology: subnets, route tables, internet access, and connectivity to other networks.

Key design principles:

  • Use private subnets by default. Only place resources in public subnets if they must accept inbound traffic from the internet (load balancers, bastion hosts).
  • Separate tiers into different subnets. Web tier, application tier, and data tier should be in distinct subnets with different security group rules.
  • Use multiple Availability Zones. This is primarily for availability, but it also ensures your security controls work consistently across AZs.

Security Groups vs NACLs​

These are the two firewall mechanisms in a VPC. They operate at different levels and have fundamentally different behavior.

FeatureSecurity GroupsNACLs
LevelENI (instance/resource)Subnet
StateStatefulStateless
RulesAllow onlyAllow and Deny
EvaluationAll rules evaluated togetherRules evaluated in order (lowest number first)
DefaultDeny all inbound, allow all outboundAllow all inbound and outbound
Return trafficAutomatically allowedMust be explicitly allowed

Stateful vs Stateless​

Security groups are stateful. If you allow inbound traffic on port 443, the response traffic is automatically allowed out, regardless of outbound rules. You only need to write rules for the initiating direction.

NACLs are stateless. If you allow inbound traffic on port 443, you must also explicitly allow the outbound response traffic (typically on ephemeral ports 1024-65535). Forgetting the return rule is a common mistake.

When to Use Each​

  • Security groups are your primary firewall. Use them on every resource to control access at the instance level.
  • NACLs are a secondary layer. Use them for broad subnet-level rules, such as blocking known malicious IP ranges or enforcing that certain subnets cannot communicate with each other.

Private Subnets, NAT Gateways, and VPC Endpoints​

Private Subnets​

Resources in private subnets have no direct internet access. They cannot receive inbound traffic from the internet, and they cannot initiate outbound connections without a NAT gateway. This is the correct default for databases, application servers, and Lambda functions.

NAT Gateways​

A NAT gateway allows resources in private subnets to initiate outbound connections to the internet (for software updates, external API calls) without exposing them to inbound traffic. NAT gateways are placed in public subnets and route traffic through an internet gateway.

Cost consideration: NAT gateways charge per hour and per GB processed. For high-traffic workloads, this cost adds up quickly.

VPC Endpoints​

VPC endpoints provide private connectivity to AWS services without going through the internet or a NAT gateway. There are two types:

TypeServicesHow It Works
Gateway endpointsS3, DynamoDBRoute table entry; no cost for the endpoint itself
Interface endpoints100+ services (SQS, KMS, SSM, etc.)ENI in your subnet; charged per hour + per GB

When to Use VPC Endpoints vs NAT Gateway​

  • Use VPC endpoints when your private resources need to access AWS services. This is more secure (traffic stays on the AWS network) and often cheaper than NAT gateway data processing fees.
  • Use NAT gateways when your private resources need to reach the public internet (third-party APIs, package repositories).
  • Use both in most production environments. VPC endpoints for AWS services, NAT gateway for external internet access.

Terraform: VPC with Public/Private Subnets and Security Groups​

# Production-ready VPC with public and private subnets,
# NAT gateway, and tiered security groups

data "aws_availability_zones" "available" {
state = "available"
}

locals {
azs = slice(data.aws_availability_zones.available.names, 0, 2)
}

# --- VPC ---

resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true

tags = {
Name = "production-vpc"
ManagedBy = "terraform"
}
}

# --- Subnets ---

resource "aws_subnet" "public" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = local.azs[count.index]

map_public_ip_on_launch = false # Explicitly disable auto-assign public IP

tags = {
Name = "public-${local.azs[count.index]}"
Tier = "public"
}
}

resource "aws_subnet" "private_app" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
availability_zone = local.azs[count.index]

tags = {
Name = "private-app-${local.azs[count.index]}"
Tier = "private-app"
}
}

resource "aws_subnet" "private_data" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 20)
availability_zone = local.azs[count.index]

tags = {
Name = "private-data-${local.azs[count.index]}"
Tier = "private-data"
}
}

# --- Internet Gateway ---

resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id

tags = {
Name = "production-igw"
}
}

# --- NAT Gateway ---

resource "aws_eip" "nat" {
domain = "vpc"

tags = {
Name = "nat-gateway-eip"
}
}

resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id

tags = {
Name = "production-nat-gw"
}

depends_on = [aws_internet_gateway.main]
}

# --- Route Tables ---

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
}

tags = {
Name = "public-rt"
}
}

resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id

route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}

tags = {
Name = "private-rt"
}
}

resource "aws_route_table_association" "public" {
count = length(local.azs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "private_app" {
count = length(local.azs)
subnet_id = aws_subnet.private_app[count.index].id
route_table_id = aws_route_table.private.id
}

resource "aws_route_table_association" "private_data" {
count = length(local.azs)
subnet_id = aws_subnet.private_data[count.index].id
route_table_id = aws_route_table.private.id
}

# --- Security Groups ---

# ALB security group: allow HTTPS from the internet
resource "aws_security_group" "alb" {
name_prefix = "alb-"
vpc_id = aws_vpc.main.id
description = "Security group for application load balancer"

ingress {
description = "HTTPS from internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}

egress {
description = "Forward to application tier"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}

tags = {
Name = "alb-sg"
Tier = "public"
}
}

# Application security group: only accept traffic from ALB
resource "aws_security_group" "app" {
name_prefix = "app-"
vpc_id = aws_vpc.main.id
description = "Security group for application tier"

ingress {
description = "Traffic from ALB"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}

egress {
description = "Outbound to internet via NAT"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}

tags = {
Name = "app-sg"
Tier = "private-app"
}
}

# Database security group: only accept traffic from app tier
resource "aws_security_group" "database" {
name_prefix = "db-"
vpc_id = aws_vpc.main.id
description = "Security group for database tier"

ingress {
description = "PostgreSQL from app tier"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}

# No egress to internet - databases should not initiate outbound connections
egress {
description = "Allow responses to app tier"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [aws_vpc.main.cidr_block]
}

tags = {
Name = "database-sg"
Tier = "private-data"
}
}

# --- VPC Endpoint for S3 (Gateway - no cost) ---

resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${data.aws_availability_zones.available.id}.s3"

route_table_ids = [
aws_route_table.private.id
]

tags = {
Name = "s3-vpc-endpoint"
}
}

This configuration demonstrates several security principles:

  • Tiered subnets: Public, private-app, and private-data subnets are separated.
  • Security group chaining: The ALB only forwards to the app tier, the app tier only connects to the database. No direct path exists from the internet to the database.
  • Restricted database egress: The database security group only allows outbound traffic within the VPC, not to the internet.
  • VPC endpoint for S3: Private resources access S3 without traversing the NAT gateway.

Flashcards​

1 / 7
Question

What is the key difference between security groups and NACLs?

Click to reveal
Answer

Security groups are stateful (return traffic automatically allowed) and operate at the ENI level. NACLs are stateless (return traffic must be explicitly allowed) and operate at the subnet level.