---
author: Phil Stevenson
title: "Ideal AWS VPC in Terraform"
date: 2026-05-11
published: true
description: "Compute subnet CIDR blocks programmatically from a base /20 VPC CIDR, keeping each traffic tier contiguous with room to add AZs later."
tags: ["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.

<!--more-->

## 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.

```text
# 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:

```hcl
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:

```hcl
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
