Recipe: AWS VPC
Almost every AWS workload starts with a Virtual Private Cloud—an isolated network where your instances, load balancers, and managed services live. A production-grade VPC spreads subnets across multiple Availability Zones, separates public-facing resources from private ones, and routes egress through managed gateways. This recipe builds exactly that: a multi-AZ VPC with public and private subnets, an internet gateway, NAT gateways, route tables, and clean outputs—driven entirely by locals and for_each so adding a third AZ is a one-line change. Everything here works identically on Terraform 1.5+ and OpenTofu.
Designing the address space
Before any HCL, decide on a CIDR plan. We give the VPC a /16 (65,536 addresses) and carve /24 subnets out of it with the built-in cidrsubnet function. Encoding the layout in locals keeps subnet math in one place and lets the rest of the configuration stay declarative.
locals {
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
# Map each AZ to a deterministic /24, offset so public and
# private ranges never overlap.
public_subnets = {
for idx, az in local.azs : az => cidrsubnet(local.vpc_cidr, 8, idx)
}
private_subnets = {
for idx, az in local.azs : az => cidrsubnet(local.vpc_cidr, 8, idx + 100)
}
tags = {
Project = "devcraftly"
ManagedBy = "terraform"
}
}
The two for expressions produce maps keyed by AZ name—for example { "us-east-1a" = "10.0.0.0/24", ... }. Keying by AZ rather than by integer index is what makes for_each later resistant to churn: removing an AZ destroys only that AZ’s resources.
The VPC and internet gateway
The VPC itself is a single resource. Enabling DNS support and hostnames is required for most managed services (RDS, EKS, private endpoints) to resolve correctly.
resource "aws_vpc" "main" {
cidr_block = local.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(local.tags, { Name = "devcraftly-vpc" })
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(local.tags, { Name = "devcraftly-igw" })
}
Subnets across Availability Zones
Both subnet tiers use for_each over the maps from locals. Public subnets set map_public_ip_on_launch so instances receive a routable address; private subnets do not.
resource "aws_subnet" "public" {
for_each = local.public_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
map_public_ip_on_launch = true
tags = merge(local.tags, { Name = "public-${each.key}", Tier = "public" })
}
resource "aws_subnet" "private" {
for_each = local.private_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
tags = merge(local.tags, { Name = "private-${each.key}", Tier = "private" })
}
NAT gateways for private egress
Private subnets need outbound internet access (package mirrors, API calls) without being reachable from outside. A NAT gateway provides that, and it lives in a public subnet with an Elastic IP. For high availability we deploy one NAT gateway per AZ; for cost-sensitive environments a single shared NAT is a valid trade-off.
resource "aws_eip" "nat" {
for_each = aws_subnet.public
domain = "vpc"
tags = merge(local.tags, { Name = "nat-eip-${each.key}" })
}
resource "aws_nat_gateway" "main" {
for_each = aws_subnet.public
allocation_id = aws_eip.nat[each.key].id
subnet_id = each.value.id
tags = merge(local.tags, { Name = "nat-${each.key}" })
depends_on = [aws_internet_gateway.main]
}
NAT gateways and their Elastic IPs are billed hourly plus per-GB processed, even when idle. One-NAT-per-AZ multiplies that cost—use
for_eachover a single public subnet (or avar.single_nat_gatewaytoggle) for dev environments.
Route tables
The public route table sends 0.0.0.0/0 to the internet gateway and is shared by all public subnets. Each private subnet gets its own route table pointing at the NAT gateway in its own AZ, so traffic never crosses zones.
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 = merge(local.tags, { Name = "public-rt" })
}
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" {
for_each = aws_subnet.private
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[each.key].id
}
tags = merge(local.tags, { Name = "private-rt-${each.key}" })
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private[each.key].id
}
Outputs
Expose the IDs downstream modules need—the VPC ID and lists of subnet IDs by tier.
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = [for s in aws_subnet.public : s.id]
}
output "private_subnet_ids" {
value = [for s in aws_subnet.private : s.id]
}
Applying the recipe
Initialize, review, and apply:
terraform init
terraform plan
terraform apply
Output:
Plan: 21 to add, 0 to change, 0 to destroy.
aws_vpc.main: Creation complete after 2s [id=vpc-0a1b2c3d4e5f60718]
aws_internet_gateway.main: Creation complete after 1s [id=igw-0fa9...]
aws_subnet.public["us-east-1a"]: Creation complete after 1s [id=subnet-0c1...]
aws_nat_gateway.main["us-east-1b"]: Still creating... [1m20s elapsed]
aws_nat_gateway.main["us-east-1b"]: Creation complete after 1m31s [id=nat-08d...]
Apply complete! Resources: 21 added, 0 changed, 0 destroyed.
Outputs:
private_subnet_ids = ["subnet-0aa...", "subnet-0bb...", "subnet-0cc..."]
public_subnet_ids = ["subnet-0c1...", "subnet-0c2...", "subnet-0c3..."]
vpc_id = "vpc-0a1b2c3d4e5f60718"
NAT gateways dominate the apply time; everything else provisions in seconds.
Best Practices
- Centralize CIDR math in
localswithcidrsubnetso subnet ranges are deterministic and never overlap by accident. - Drive subnets, NAT gateways, and route tables with
for_eachkeyed by AZ—scaling to another zone is a single addition tolocal.azs. - Keep one route table (and ideally one NAT gateway) per private AZ so egress stays in-zone and an AZ outage is isolated.
- Enable
enable_dns_supportandenable_dns_hostnames; many managed services silently fail to resolve without them. - Gate per-AZ NAT gateways behind a variable so non-production stacks can fall back to a single NAT and cut cost.
- Output subnet IDs as tier-specific lists so consuming modules (EC2, EKS, RDS) never hard-code network details.