← Back to blog

Ideal AWS VPC in Terraform

· Phil Stevenson
aws cidr iac networking terraform vpc

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 /20 and all subnets recalculate
  • hashicorp/subnets/cidr: sequential CIDR allocation from a base block. new_bits controls size — 3 for /23, 4 for /24
  • Environment-driven: a single environment local picks the VPC name and CIDR. No long ternary chains
  • Tags for tier discovery: downstream resources use tier tags, not CIDR references