Networking is where Azure habits mislead the most. The vocabulary looks identical - VNet, subnet, NSG, peering - but the defaults, the stateful/stateless split, and the cost model are all different. This chapter rewires those reflexes before they cost you a NAT-gateway-sized bill.
An Azure VNet feels like a polite suburban street: NSGs are stateful, the default subnet is private, and most traffic flows the way you expect. An AWS VPC feels like the same street but with two sets of gate guards (security groups and NACLs) at every house, no default outbound route, and a NAT toll-booth that charges per byte. Same goal, different machinery. We'll build the model piece by piece, then provision the VPC that Project Compass will live in.
| Concept | Azure | AWS |
|---|---|---|
| Virtual network | VNet (per region) | VPC (per region, per account) |
| Subnet | Subnet (a VNet partition) | Subnet (lives in exactly one AZ) |
| Default network ACL | NSG (stateful, applied to subnet or NIC) | Security Group (stateful, NIC level) + NACL (stateless, subnet level) |
| "NSG-equivalent" | single concept | split in two: SG = "who can talk to me?", NACL = "what packets enter/leave this subnet?" |
| Jump host / admin access | Azure Bastion (managed) | Systems Manager Session Manager (no bastion, no SSH key, no port 22) |
| Hub-and-spoke | vWAN / VNet hub | Transit Gateway (TGW) |
| Peering | VNet peering (transitive via vWAN) | VPC peering (NEVER transitive - this trips Azure devs) |
| Private link to PaaS | Private Endpoint | VPC Endpoint (Gateway for S3/DynamoDB free; Interface for others, hourly + GB) |
| Public IPv4 | Public IP (Basic or Standard SKU) | Elastic IP (charged when NOT attached, since Feb 2024 also charged when attached) |
| Outbound from private | NAT Gateway (per VNet, regional) | NAT Gateway (per AZ - you generally want one per AZ for HA) |
| L4/L7 load balancer | Standard LB / Application Gateway | NLB (L4) / ALB (L7) / GWLB (firewall chaining) |
| DNS | Azure DNS Private Zones | Route 53 Private Hosted Zones (associated to VPCs) |
| Default subnet behavior | All subnets effectively private; you choose to expose | Default VPC has all-public subnets with 0.0.0.0/0 via IGW. Production-grade VPCs you build yourself |
i-abc instance and a stranger's i-def could see each other's IPs. VPC was AWS retrofitting tenant isolation. EC2-Classic was fully retired in August 2022, only 13 years later./16 in 10.0.0.0/16 means "the first 16 bits are the network, the remaining 16 are hosts" - 65,536 addresses. Pronounced "cider" by most. AWS VPC CIDR must be between /16 (large) and /28 (16 addresses, tiny).The IP address range, fixed at creation. 10.0.0.0/16 = 65,536 addresses. AWS allows /16 down to /28. You cannot change the primary CIDR after the VPC exists - you can only add secondary CIDRs.
Azure analog: VNet address space (multiple ranges supported, can be edited).
Each subnet lives in one AZ and carves a slice of the VPC CIDR. Typical layout: one public + one private subnet per AZ, spread across 2-3 AZs. Each subnet is either public or private based on its route table.
Azure analog: Azure subnets are zone-redundant by default; AWS forces you to choose an AZ.
A subnet's route table is what determines public vs private. Has a default route for 0.0.0.0/0: pointed at an IGW = public subnet, pointed at a NAT GW = private-with-egress, no default route = fully isolated.
Azure analog: User-defined routes (UDRs) attached to subnets.
| Range | Size | When to use |
|---|---|---|
10.0.0.0/16 | 65,536 IPs | Default mental choice. Plenty of room, easy to remember, won't collide with the typical home network. |
172.16.0.0/12 | 1M IPs (split into /16s) | Useful when you have many VPCs and need a coordinated address plan. |
192.168.0.0/16 | 65,536 IPs | Avoid - overlaps with most home/office networks, makes VPN setups painful. |
A /24 per subnet | 251 usable IPs (AWS reserves 5) | Standard subnet size. AWS reserves the first 4 and last 1 of every subnet. |
/16 CIDR convention for VPCs is partly a holdover from that era: AWS engineers wanted "big enough you won't run out, small enough not to bite into the broader 10.x space you might want for other VPCs". EC2-Classic was finally shut down in August 2022 - some workloads ran on it for sixteen years.
amazon-vpc-cni plugin gives each pod a real VPC IP from those secondary slots - so a t3.medium (3 ENIs * 6 IPs - 1 primary = 17 pods max) caps pod density purely on network plumbing. Teams hit this before they hit CPU/memory limits and stare at the cluster wondering why pods are Pending. Fixes: bigger instance type, or switch to prefix delegation mode (chapter 10).
A subnet is "public" or "private" only because of its route table. Same subnet, different route table = different connectivity. This is the level Azure abstracts away.
| Route table has | Subnet behavior |
|---|---|
0.0.0.0/0 -> igw-xxx | Public subnet. Instances need a public IP / EIP to be reachable; they can talk out via IGW. |
0.0.0.0/0 -> nat-xxx | Private-with-egress. Instances have only private IPs; outbound goes through NAT GW. |
No 0.0.0.0/0 entry | Isolated. Only local VPC traffic and explicit endpoints work. Data tiers often look like this. |
0.0.0.0/0 -> tgw-xxx | Egress to a Transit Gateway (on-prem via DX/VPN, hub-and-spoke). |
# Azure: a UDR overriding default routes
resource "azurerm_route_table" "private" {
name = "rt-private"
location = "eastus"
resource_group_name = "rg-net"
route {
name = "default-via-fw"
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = "10.0.5.4"
}
}
resource "azurerm_subnet_route_table_association" "app" {
subnet_id = azurerm_subnet.app.id
route_table_id = azurerm_route_table.private.id
}
# Implicit: Azure auto-routes to internet unless you override
# AWS: a route table, a route, and an association
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = { Name = "rt-private-a" }
}
resource "aws_route" "private_default" {
route_table_id = aws_route_table.private.id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.az_a.id
}
resource "aws_route_table_association" "app_a" {
subnet_id = aws_subnet.app_a.id
route_table_id = aws_route_table.private.id
}
# Explicit: AWS has NO default outbound; you must add the route
local (intra-VPC). You must explicitly add 0.0.0.0/0 -> igw / nat to get out. This is a feature for security but a footgun for Azure muscle memory.
0.0.0.0/0 at the NAT.A teammate created a VPC with Terraform. They added an Internet Gateway, attached it to the VPC, marked one subnet as "public" with map_public_ip_on_launch = true, and launched an EC2 instance with an automatically-assigned public IP. The instance can't reach 0.0.0.0/0. They paste the relevant Terraform below. What's wrong?
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
}
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.0.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
# A route for VPC-local traffic to a peering connection
route {
cidr_block = "10.20.0.0/16"
vpc_peering_connection_id = aws_vpc_peering_connection.shared.id
}
}
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
0.0.0.0/0 -> igw entry.
Without that default route, the subnet is effectively private with no egress. The IGW exists, the subnet is correctly associated, the instance even has a public IP - but every outbound packet hits a route table that only knows about 10.0.0.0/16 (local, implicit) and 10.20.0.0/16 (the peering). Anything else gets dropped.
Fix: add the missing route.
resource "aws_route" "public_default" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
"Public subnet" in AWS is shorthand for "subnet whose route table has 0.0.0.0/0 pointing at an IGW". No route, no public.
A team runs a 3-AZ HA VPC with three NAT Gateways (one per AZ - the right HA pattern). Baseline is fine: 3 NAT GW * ~$32/month = ~$100/month. Two months later the AWS bill jumps to ~$10,300. The team didn't change architecture, didn't add traffic, didn't change pricing. What happened?
1. NAT data-processing: $0.045/GB. Every byte that traverses a NAT Gateway is billed at $0.045/GB - in both directions. This is on top of the per-hour cost and on top of internet-egress charges.
2. Inter-AZ traffic accidentally routed through NAT. A new EKS workload talked to an S3 bucket in the same region. By default S3 traffic from private subnets goes out via NAT, to the public S3 endpoint, and back. Worse: the team's pods in az-a were hitting a NAT in az-b because of a misconfigured node selector. Every byte cost $0.045 going out and $0.045 coming back, plus inter-AZ transfer at $0.01/GB. At ~30TB/day, the monthly bill landed around $10K.
Fix:
iptables -t nat) can be 50x cheaper - the trade-off is you operate it."NAT GW costs surprised us" is the #1 AWS cost incident story on r/aws. Read the NAT entry on the bill carefully every month.
Stateful, ENI-level. Default deny on inbound, default allow on outbound. Return traffic for allowed inbound is automatically allowed. Up to 60 inbound + 60 outbound rules per SG; up to 5 SGs per ENI.
Closest Azure analog: NSG attached to a NIC.
Stateless, subnet-level. Numbered rules (low to high), explicit Allow/Deny, implicit deny at end. Return traffic must be allowed explicitly (ephemeral ports!). One NACL per subnet; default NACL allows all.
Closest Azure analog: NSG attached to a subnet, but stateless - Azure has nothing identical.
For a packet to flow it must pass both. NACL evaluates first on inbound (at the subnet boundary), then SG (at the instance ENI). On outbound, SG first, then NACL. Either layer can deny.
Belt + suspenders. Most teams set the NACL to "allow all" and rely on SGs.
| Behavior | Azure NSG | AWS Security Group |
|---|---|---|
| Stateful | Yes - both directions | Yes - both directions (same as Azure) |
| Default inbound | Deny (except AllowVNetInBound which lets intra-VNet through) | Deny (no implicit "allow VPC" rule) |
| Default outbound | Allow (with implicit AllowInternetOutBound) | Allow 0.0.0.0/0 - first thing many lock down |
| Reference another security primitive | Application Security Group (ASG) | Security Group itself - "allow from sg-abc" as the source |
| Priority-based | Yes - lowest number wins | No priority - all rules are OR'd; if any allows, packet passes |
| Explicit Deny rule | Yes - Deny action with priority | NO - SG rules are allow-only. To deny, you simply omit. (NACL is where deny lives.) |
| Default subnet rules | NSG attached either to subnet or NIC; default is "no NSG" | Every subnet has a NACL (default = allow all); every ENI has an SG (default = allow VPC traffic out only) |
resource "azurerm_network_security_group" "web" {
name = "nsg-web"
location = "eastus"
resource_group_name = "rg-app"
security_rule {
name = "allow-https"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
resource "aws_security_group" "web" {
name = "sg-web"
description = "web tier"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "https" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 443
to_port = 443
}
resource "aws_vpc_security_group_egress_rule" "out" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1" # all protocols
}
This isn't one story - it's the pattern. A developer launches an EC2 instance for a quick test. The wizard asks about an SSH security group; they pick "Anywhere (0.0.0.0/0)" because they want to SSH from a coffee shop. The instance gets a public IP, the SG allows port 22 from the world.
Honeytoken and SSH-honeypot studies (most widely cited: the 2021 Palo Alto Networks Unit 42 honeypot research) consistently show that an SSH endpoint on a fresh public AWS IP starts receiving credential-stuffing attempts within minutes. Median time-to-first-attack across multiple cloud honeypot studies: under 4 minutes. Common outcomes once a weak password (or worse, a leaked SSH key) is guessed: crypto miners running on the instance, the instance used as a jump box into the rest of the VPC, IAM credentials exfiltrated via IMDS if IMDSv1 is enabled.
The blast radius depends on what else is on that instance. Stories range from "$3K of crypto-mining EC2 charges in 48 hours before the alert tripped" to "attacker pivoted via IMDS, found an admin role, deleted production".
Lessons: (1) Never expose port 22 to 0.0.0.0/0. Use Systems Manager Session Manager - no port 22, no public IP, no SSH key. (2) Enforce IMDSv2 on every instance (the v1 endpoint is unauthenticated). (3) Use SCPs to block 0.0.0.0/0 on port 22 ingress entirely at the org level. We wire this up in chapters 8 and 12.
Two VPCs need to talk. You have two options: a direct peering connection or a Transit Gateway hub. Azure's mental model maps cleanly, but one rule is loudly different.
Direct, point-to-point. One peering per pair of VPCs. Same region or cross-region. Update both route tables to add the peer's CIDR. Cheap (no hourly fee, only data transfer).
Azure analog: VNet peering. Same idea, but Azure peerings can be "use remote gateway" - AWS peerings can't.
Hub-and-spoke. Attach many VPCs and VPNs to one TGW. Acts as a regional router. Hourly + per-GB cost, but scales to dozens or hundreds of VPCs without N-squared peerings.
Azure analog: Azure vWAN / VNet hub.
VPC peering is NEVER transitive. If A peers with B and B peers with C, A still cannot talk to C. You need a third peering A-C, or you put all three on a TGW. Azure has the same rule officially, but vWAN abstracts it.
| Connectivity | Hourly fee | Data transfer |
|---|---|---|
| VPC peering (same region) | $0 | $0.01/GB (each direction, inter-AZ only) |
| VPC peering (cross-region) | $0 | $0.02/GB |
| Transit Gateway attachment | ~$0.05/hour per attachment (~$36/mo each) | $0.02/GB through TGW |
| vWAN hub (Azure equivalent) | ~$0.25/hour per hub | $0.02/GB |
VPC endpoints connect a VPC privately to AWS services without traversing the public internet (and without going through your NAT Gateway). Two flavors, very different pricing.
FREE. Only available for S3 and DynamoDB. Works by adding a special route in your subnet's route table. Traffic to S3/DynamoDB matches the route and goes via the gateway endpoint instead of 0.0.0.0/0 -> NAT.
Always add these. Free. Cuts NAT bills immediately.
Paid. Available for ~200 AWS services (SQS, KMS, Secrets Manager, CloudWatch, ECR, STS, etc). Creates an ENI in your subnet with a private IP that resolves the service's hostname. Costs ~$0.01/hour per endpoint per AZ + $0.01/GB.
Used heavily by workloads that don't want NAT or need on-prem access to AWS APIs via Direct Connect.
If your private workload does 1 TB/month to S3 via NAT: 1024 GB * $0.045 = ~$46/month in NAT data processing alone, plus inter-AZ. Same TB via a Gateway Endpoint: $0. The endpoint pays for itself in seconds.
Closest Azure analog: Private Endpoint for storage accounts.
# Gateway endpoint for S3 - FREE, no ENIs, just a route entry
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_a.id,
aws_route_table.private_b.id,
]
}
# Gateway endpoint for DynamoDB - also free
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = [
aws_route_table.private_a.id,
aws_route_table.private_b.id,
]
}
# Interface endpoint for KMS - paid (~$7/month/AZ)
# But it lets workloads use KMS without going through NAT
resource "aws_vpc_endpoint" "kms" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.kms"
vpc_endpoint_type = "Interface"
subnet_ids = [aws_subnet.app_a.id, aws_subnet.app_b.id]
security_group_ids = [aws_security_group.endpoints.id]
private_dns_enabled = true
}
Goal: get comfortable with the inspection commands. We won't create anything in this lab - that's chapter 3's Project Compass slice. Here we just look at what's already there. Every new AWS account ships with one default VPC per region.
Step 1. List all VPCs in your current region.
aws ec2 describe-vpcs \
--query 'Vpcs[].{Id:VpcId,Cidr:CidrBlock,Default:IsDefault,State:State}' \
--output table
# +----------------+-------------+---------+-----------+
# | Id | Cidr | Default | State |
# +----------------+-------------+---------+-----------+
# | vpc-0abcd1234 | 172.31.0.0/16| True | available |
# +----------------+-------------+---------+-----------+
Step 2. List the default VPC's subnets - one per AZ.
VPC_ID=$(aws ec2 describe-vpcs \
--filters "Name=isDefault,Values=true" \
--query 'Vpcs[0].VpcId' --output text)
aws ec2 describe-subnets \
--filters "Name=vpc-id,Values=$VPC_ID" \
--query 'Subnets[].{Id:SubnetId,Cidr:CidrBlock,AZ:AvailabilityZone,Public:MapPublicIpOnLaunch}' \
--output table
# Note Public=True on every default subnet. That's why default VPCs aren't production-grade.
Step 3. Look at the main route table.
aws ec2 describe-route-tables \
--filters "Name=vpc-id,Values=$VPC_ID" \
--query 'RouteTables[0].Routes' \
--output table
# You'll see two routes:
# 10.x.x.x/16 -> local (intra-VPC, implicit)
# 0.0.0.0/0 -> igw-xxxx (the default VPC has an IGW pre-attached)
Step 4. Look at the default security group attached to that VPC.
aws ec2 describe-security-groups \
--filters "Name=vpc-id,Values=$VPC_ID" "Name=group-name,Values=default" \
--query 'SecurityGroups[0].{IngressRules:IpPermissions,EgressRules:IpPermissionsEgress}'
# Default SG ingress: allow from itself only (any member of this SG can talk to any other member)
# Default SG egress: 0.0.0.0/0 (allow all out)
# Default NACL: allow all in both directions
What you learned: the default VPC is a public, all-AZ, all-traffic-allowed playground. Great for quickstart tutorials, dangerous for production. The whole purpose of chapter 3's Project Compass slice is to build a non-default VPC that follows the production patterns.
VPC questions are about defaults and rules. Sit with each one before revealing.
1. Which statement about Security Groups and Azure NSGs is true?
2. A NACL has rules: 100 ALLOW tcp 443 from 0.0.0.0/0 and 110 ALLOW tcp 80 from 0.0.0.0/0. An admin adds 105 DENY tcp 443 from 1.2.3.0/24. What's the effect on a packet from 1.2.3.4:54321 to port 443?
3. You're running a 3-AZ HA app in a private subnet. Which NAT topology is correct for HA?
0.0.0.0/0 to its same-AZ NAT. Bonus: this avoids the $0.01/GB inter-AZ data transfer fee on outbound. For non-prod, option (a) is fine and saves ~$64/month.
4. VPC A peers with VPC B. VPC B peers with VPC C. From an instance in VPC A, you ping an instance in VPC C. What happens?
5. You launch an EC2 instance in your account's default VPC, accept the wizard defaults. Which is true?
0.0.0.0/0 via IGW; SSH/RDP from anywhere is allowed if you pick the "default" SG.MapPublicIpOnLaunch=true, the main route table has 0.0.0.0/0 -> igw, and the default Security Group allows all egress. If during the wizard you pick "Allow SSH from anywhere", you've just put a port-22-open instance on the public internet - the horror-story scenario. The default VPC is for quickstarts; production VPCs should be hand-built (or Terraformed - see Project Compass below).
0.0.0.0/0 open to the internet on every subnet. "Default VPC" sounds like "safe sensible default" - it isn't. It's "everything wide open for tutorials". Either delete the default VPCs in every region you don't use, or use SCPs (chapter 12) to deny default-VPC usage entirely.
10.0.0.0/16 is a good default with room to grow. You CAN add secondary CIDR blocks later, but the primary is set in stone. Worse: you cannot peer two VPCs with overlapping CIDRs at all. This is the single most common reason a "small dev VPC" needs to be rebuilt from scratch later.
Last chapter you created gfn-reports-role. The Lambda we'll build in chapter 4 will (eventually) need to live in a VPC so it can reach an RDS database in chapter 6 and an ElastiCache in chapter 9. Time to build the network.
compass-vpc - a 2-AZ VPC with public + private subnets
Why now: networking is foundational, and provisioning it once means every later chapter can drop resources into the existing VPC. We do it cost-conscious: a single NAT GW (not three), only the free S3 + DynamoDB Gateway Endpoints, no interface endpoints yet.
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
profile = "compass"
default_tags {
tags = { Project = "Compass", ManagedBy = "terraform" }
}
}
resource "aws_vpc" "main" {
cidr_block = "10.50.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "compass-vpc" }
}
locals {
azs = ["us-east-1a", "us-east-1b"]
}
# Public subnets - one per AZ. 10.50.0.0/24 and 10.50.1.0/24.
resource "aws_subnet" "public" {
for_each = toset(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = "10.50.${index(local.azs, each.key)}.0/24"
availability_zone = each.key
map_public_ip_on_launch = true
tags = { Name = "compass-public-${each.key}", Tier = "public" }
}
# Private subnets - 10.50.10.0/24 and 10.50.11.0/24.
resource "aws_subnet" "private" {
for_each = toset(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = "10.50.${10 + index(local.azs, each.key)}.0/24"
availability_zone = each.key
tags = { Name = "compass-private-${each.key}", Tier = "private" }
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = { Name = "compass-igw" }
}
# EIP for the NAT GW
resource "aws_eip" "nat" {
domain = "vpc"
depends_on = [aws_internet_gateway.igw]
tags = { Name = "compass-nat-eip" }
}
# Single NAT GW in az-a (dev-grade; for prod, one per AZ)
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public["us-east-1a"].id
tags = { Name = "compass-nat" }
depends_on = [aws_internet_gateway.igw]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = { Name = "compass-rt-public" }
}
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
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 = "compass-rt-private" }
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private.id
}
# Free Gateway Endpoints (S3 + DynamoDB) - day-one cost saver
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]
tags = { Name = "compass-s3-endpoint" }
}
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = [aws_route_table.private.id]
tags = { Name = "compass-ddb-endpoint" }
}
# SG for the Lambda when it runs in the VPC (chapter 4)
resource "aws_security_group" "lambda" {
name = "compass-lambda-sg"
description = "egress-only SG for gfn-reports Lambda"
vpc_id = aws_vpc.main.id
tags = { Name = "compass-lambda-sg" }
}
resource "aws_vpc_security_group_egress_rule" "lambda_all" {
security_group_id = aws_security_group.lambda.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
# SG placeholder for the future RDS (chapter 6). Allow tcp/5432 from Lambda SG only.
resource "aws_security_group" "rds" {
name = "compass-rds-sg"
description = "allow Postgres from Lambda SG"
vpc_id = aws_vpc.main.id
tags = { Name = "compass-rds-sg" }
}
resource "aws_vpc_security_group_ingress_rule" "rds_from_lambda" {
security_group_id = aws_security_group.rds.id
referenced_security_group_id = aws_security_group.lambda.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
# Apply
cd compass-vpc/
terraform init
terraform plan
terraform apply
# Verify
aws ec2 describe-vpcs \
--filters "Name=tag:Project,Values=Compass" \
--query 'Vpcs[].{Id:VpcId,Cidr:CidrBlock,Tags:Tags[?Key==`Name`].Value|[0]}' \
--output table --profile compass
aws ec2 describe-subnets \
--filters "Name=tag:Project,Values=Compass" \
--query 'Subnets[].{Id:SubnetId,AZ:AvailabilityZone,Cidr:CidrBlock,Public:MapPublicIpOnLaunch}' \
--output table --profile compass
aws ec2 describe-vpc-endpoints \
--filters "Name=tag:Project,Values=Compass" \
--query 'VpcEndpoints[].{Service:ServiceName,Type:VpcEndpointType,State:State}' \
--output table --profile compass
You now have a clean, non-default VPC with two AZs, four subnets (2 public + 2 private), one IGW, one NAT GW, two free Gateway Endpoints, and two security groups wired up for the future Lambda-to-RDS flow. The estimated monthly cost: about $36 (one NAT GW + EIP), before any data transfer.
terraform destroy when you're between chapters. The state file is tiny; re-applying takes ~3 minutes. The only thing you can't destroy-and-recreate freely is the VPC CIDR (which is the point of doing it via IaC - the recipe stays the same).
10.x.0.0/16 the first time.| Trap | Fix |
|---|---|
| "Public" subnet but no internet | Route table needs 0.0.0.0/0 -> igw. Existence of an IGW isn't enough. |
| Huge NAT GW bill | Add S3 + DynamoDB Gateway Endpoints (free). Audit inter-AZ NAT routing. Single NAT for non-prod. |
| "My SG has a Deny rule that doesn't work" | SGs are allow-only. Use a NACL for Deny, or omit the rule. |
| "A can reach B, B can reach C, A can't reach C" | Peering isn't transitive. Add A-C peering or move to Transit Gateway. |
| "I want to change my VPC CIDR" | You can't. Add a secondary CIDR, or rebuild the VPC. Plan address space up front. |