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.
| Feature | Security Groups | NACLs |
|---|---|---|
| Level | ENI (instance/resource) | Subnet |
| State | Stateful | Stateless |
| Rules | Allow only | Allow and Deny |
| Evaluation | All rules evaluated together | Rules evaluated in order (lowest number first) |
| Default | Deny all inbound, allow all outbound | Allow all inbound and outbound |
| Return traffic | Automatically allowed | Must 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:
| Type | Services | How It Works |
|---|---|---|
| Gateway endpoints | S3, DynamoDB | Route table entry; no cost for the endpoint itself |
| Interface endpoints | 100+ 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​
- Terraform
- CDK (TypeScript)
- CDK (Python)
- CloudFormation
# 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"
}
}
CDK & CloudFormation Templates
Get this implementation in AWS CDK (TypeScript/Python) and CloudFormation YAML.
CDK & CloudFormation Templates
Get this implementation in AWS CDK (TypeScript/Python) and CloudFormation YAML.
CDK & CloudFormation Templates
Get this implementation in AWS CDK (TypeScript/Python) and CloudFormation YAML.
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​
What is the key difference between security groups and NACLs?
Click to revealSecurity 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.