Ideal AWS VPC in Terraform
A clean pattern for defining a VPC with public, private, and intra subnets across 3 AZs using CIDR blocks calculated programmatically. Includes an unused 4th block reserved for future expansion.
Subnet layout
Each traffic tier gets 4 blocks, one per AZ plus an unused spare. The unused block means you can add a 4th AZ later without renumbering anything — just set the AZ name in vpc_zones and the CIDR is already allocated.
# Subnet layout for a /20 VPC CIDR in eu-west-1:
# public-eu-west-1a = "10.188.144.0/24"
# public-eu-west-1b = "10.188.145.0/24"
# public-eu-west-1c = "10.188.146.0/24"
# public-unused = "10.188.147.0/24"
# intra-eu-west-1a = "10.188.148.0/24"
# intra-eu-west-1b = "10.188.149.0/24"
# intra-eu-west-1c = "10.188.150.0/24"
# intra-unused = "10.188.151.0/24"
# private-eu-west-1a = "10.188.152.0/23"
# private-eu-west-1b = "10.188.154.0/23"
# private-eu-west-1c = "10.188.156.0/23"
# private-unused = "10.188.158.0/23"
All public blocks sit together, then all intra blocks, then all private blocks. Each tier is contiguous, which makes the CIDR layout easy to reason about. The /23 private subnets give you more IPs per AZ for workloads that need them, while the /24 public and intra subnets keep things tight.
Programmatic CIDR calculation
Rather than hardcoding CIDR ranges per environment, compute them from a base VPC CIDR using the hashicorp/subnets/cidr module:
locals {
# Provide these as variables or locals in your environment
environment = "ew1-dev" # e.g. ew1-dev, ew1-staging, ew1-prod
vpc_names = {
ew1-dev = "vpc-ew1-dev"
ew1-staging = "vpc-ew1-staging"
ew1-prod = "vpc-ew1-prod"
}
vpc_cidrs = {
ew1-dev = "10.188.144.0/20"
ew1-staging = "10.50.0.0/20"
ew1-prod = "10.122.128.0/20"
}
# 3 AZs + 1 unused block for future expansion
vpc_zones = concat(
slice(data.aws_availability_zones.available.names, 0, 3),
["unused"]
)
vpc_cidr = local.vpc_cidrs[local.environment]
# Compute all subnet CIDR blocks: /24 for public/intra, /23 for private
vpc_networks = flatten([
for base_name in ["public", "intra", "private"] : [
for i in range(length(local.vpc_zones)) : {
name = format("%s-%s", base_name, local.vpc_zones[i])
new_bits = base_name == "private" ? 3 : 4
}
]
])
vpc_public_subnets = [
for az in local.vpc_zones :
module.vpc_subnets.network_cidr_blocks[format("public-%s", az)]
if az != "unused"
]
vpc_private_subnets = [
for az in local.vpc_zones :
module.vpc_subnets.network_cidr_blocks[format("private-%s", az)]
if az != "unused"
]
vpc_intra_subnets = [
for az in local.vpc_zones :
module.vpc_subnets.network_cidr_blocks[format("intra-%s", az)]
if az != "unused"
]
}
# Calculate CIDR blocks programmatically from the base VPC CIDR
module "vpc_subnets" {
source = "hashicorp/subnets/cidr"
version = "1.0.0"
base_cidr_block = local.vpc_cidr
networks = local.vpc_networks
}
The flatten pattern builds all subnet definitions in a single list: for each tier, for each AZ, generate a {name, new_bits} entry. The new_bits value controls the subnet size — 3 new bits gives you a /23 (private), 4 new bits gives you a /24 (public/intra). The module calculates the actual CIDR ranges sequentially from the base, so the order of vpc_networks determines the address layout.
The subnet lists passed to the VPC module filter out the "unused" entry so the VPC only creates subnets in real AZs. The unused blocks stay allocated in the CIDR math but don’t create resources until you add a 4th AZ to vpc_zones.
VPC module
The terraform-aws-modules/vpc/aws module wires everything together:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "6.6.1"
name = local.vpc_names[local.environment]
cidr = local.vpc_cidr
azs = local.vpc_zones
public_subnets = local.vpc_public_subnets
private_subnets = local.vpc_private_subnets
intra_subnets = local.vpc_intra_subnets
enable_dns_support = true
enable_dns_hostnames = true
enable_nat_gateway = true
public_subnet_tags = {
tier = "public"
}
private_subnet_tags = {
tier = "private"
}
intra_subnet_tags = {
tier = "intra"
}
}
Subnet tags let downstream resources (route tables, security groups, auto scaling groups) identify each tier without coupling to CIDR ranges or AZ names.
Key points
- 4 blocks per tier: 3 real AZs + 1 unused spare. Adding a 4th AZ means changing one value in
vpc_zones— the CIDR is already reserved - Contiguous by traffic type: all public subnets together, then intra, then private. Easy to scan and reason about
- Programmatic CIDRs: no hardcoded subnet ranges per environment. Change the base
/20and all subnets recalculate hashicorp/subnets/cidr: sequential CIDR allocation from a base block.new_bitscontrols size — 3 for/23, 4 for/24- Environment-driven: a single
environmentlocal picks the VPC name and CIDR. No long ternary chains - Tags for tier discovery: downstream resources use
tiertags, not CIDR references