Use Case — Create the US prod VPC in AWS Oregon (us-west-2)
| Subsidiary | US (10.0.0.0/12) |
| Provider / region | AWS · us-west-2 (Oregon) |
| Environment | prod |
| VPC CIDR | 10.1.0.0/20 (4,096 addresses) |
| Subnets | 12 — 4 tiers × 3 availability zones |
1. Where this VPC sits in the plan
The VPC inherits its range top-down from the enterprise IPAM hierarchy:
US subsidiary 10.0.0.0/12
AWS 10.0.0.0/14
us-west-2 10.1.0.0/16
prod env 10.1.0.0/18
VPC 10.1.0.0/20 <-- this use case
A /20 divides into exactly four /22 blocks — one per tier. Each /22 holds the three availability-zone /24 subnets plus one spare /24 for growth, so every tier is also a clean summary route.
2. Subnet plan
Twelve subnets, grouped by tier. The leftover /24 in each /22 (.3, .7, .11, .15) is reserved headroom.
Tier (/22 summary) |
Subnet | AZ | CIDR | Default route |
|---|---|---|---|---|
Public — 10.1.0.0/22 |
pub1 | us-west-2a | 10.1.0.0/24 |
Internet Gateway |
| pub2 | us-west-2b | 10.1.1.0/24 |
Internet Gateway | |
| pub3 | us-west-2c | 10.1.2.0/24 |
Internet Gateway | |
App — 10.1.4.0/22 |
app1 | us-west-2a | 10.1.4.0/24 |
NAT (per-AZ) |
| app2 | us-west-2b | 10.1.5.0/24 |
NAT (per-AZ) | |
| app3 | us-west-2c | 10.1.6.0/24 |
NAT (per-AZ) | |
Data — 10.1.8.0/22 |
data1 | us-west-2a | 10.1.8.0/24 |
none (isolated) |
| data2 | us-west-2b | 10.1.9.0/24 |
none (isolated) | |
| data3 | us-west-2c | 10.1.10.0/24 |
none (isolated) | |
Mgmt — 10.1.12.0/22 |
mgmt1 | us-west-2a | 10.1.12.0/24 |
NAT (per-AZ) |
| mgmt2 | us-west-2b | 10.1.13.0/24 |
NAT (per-AZ) | |
| mgmt3 | us-west-2c | 10.1.14.0/24 |
NAT (per-AZ) |
Layout across the three zones:
VPC · us-west-2 (Oregon) · 10.1.0.0/20
│
├─ us-west-2a ├─ us-west-2b ├─ us-west-2c
│ pub1 10.1.0.0/24 │ pub2 10.1.1.0/24 │ pub3 10.1.2.0/24
│ app1 10.1.4.0/24 │ app2 10.1.5.0/24 │ app3 10.1.6.0/24
│ data1 10.1.8.0/24 │ data2 10.1.9.0/24 │ data3 10.1.10.0/24
│ mgmt1 10.1.12.0/24 │ mgmt2 10.1.13.0/24 │ mgmt3 10.1.14.0/24
Usable hosts: AWS reserves 5 addresses per subnet, so each /24 yields 251 assignable addresses.3. Routing design
The route tables enforce the security posture of each tier:
- Public — egress and ingress through an Internet Gateway. These subnets host the NAT gateways and any internet-facing load balancers.
- App and Mgmt — private, outbound-only through a per-AZ NAT gateway. One NAT per AZ means a single AZ failure does not sever connectivity for the others.
- Data — no default route at all. Databases are reachable only from within the VPC (and via peering/transit you explicitly add).
4. Terraform implementation
###############################################################################
# US office — AWS Oregon (us-west-2) — prod VPC
#
# Position in the enterprise IPAM hierarchy:
# US subsidiary 10.0.0.0/12
# AWS 10.0.0.0/14
# us-west-2 10.1.0.0/16
# prod env 10.1.0.0/18
# VPC 10.1.0.0/20 <-- this file
#
# 12 subnets = 4 tiers x 3 AZs. Each tier is a /22 summary holding three /24s
# (one per AZ) plus a spare /24 for growth.
###############################################################################
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = local.region
}
###############################################################################
# Locals — the single place that defines the addressing plan
###############################################################################
locals {
region = "us-west-2"
vpc_cidr = "10.1.0.0/20"
# Common tags applied to every resource.
tags = {
subsidiary = "us"
provider = "aws"
region = "us-west-2"
environment = "prod"
managed_by = "terraform"
ipam_plan = "us-aws-usw2-prod"
}
# The three availability zones this VPC spans.
azs = ["us-west-2a", "us-west-2b", "us-west-2c"]
# All 12 subnets, keyed by name. CIDRs come straight from the plan above.
# tier = public | app | data | mgmt
# public -> route to Internet Gateway
# app, mgmt -> route to per-AZ NAT gateway (outbound only)
# data -> no default route (VPC-internal only)
subnets = {
pub1 = { cidr = "10.1.0.0/24", az = "us-west-2a", tier = "public" }
pub2 = { cidr = "10.1.1.0/24", az = "us-west-2b", tier = "public" }
pub3 = { cidr = "10.1.2.0/24", az = "us-west-2c", tier = "public" }
app1 = { cidr = "10.1.4.0/24", az = "us-west-2a", tier = "app" }
app2 = { cidr = "10.1.5.0/24", az = "us-west-2b", tier = "app" }
app3 = { cidr = "10.1.6.0/24", az = "us-west-2c", tier = "app" }
data1 = { cidr = "10.1.8.0/24", az = "us-west-2a", tier = "data" }
data2 = { cidr = "10.1.9.0/24", az = "us-west-2b", tier = "data" }
data3 = { cidr = "10.1.10.0/24", az = "us-west-2c", tier = "data" }
mgmt1 = { cidr = "10.1.12.0/24", az = "us-west-2a", tier = "mgmt" }
mgmt2 = { cidr = "10.1.13.0/24", az = "us-west-2b", tier = "mgmt" }
mgmt3 = { cidr = "10.1.14.0/24", az = "us-west-2c", tier = "mgmt" }
}
# Convenience groupings.
public_subnets = { for k, v in local.subnets : k => v if v.tier == "public" }
private_subnets = { for k, v in local.subnets : k => v if contains(["app", "mgmt"], v.tier) }
data_subnets = { for k, v in local.subnets : k => v if v.tier == "data" }
}
###############################################################################
# VPC
###############################################################################
resource "aws_vpc" "main" {
cidr_block = local.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(local.tags, { Name = "us-aws-usw2-prod-vpc" })
}
# To pull this CIDR from AWS IPAM instead of hardcoding it, drop cidr_block and use:
# ipv4_ipam_pool_id = aws_vpc_ipam_pool.us_aws_usw2.id
# ipv4_netmask_length = 20
###############################################################################
# Subnets (all 12)
###############################################################################
resource "aws_subnet" "this" {
for_each = local.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
# Only public subnets auto-assign public IPs.
map_public_ip_on_launch = each.value.tier == "public"
tags = merge(local.tags, {
Name = "us-aws-usw2-prod-${each.key}"
tier = each.value.tier
})
}
###############################################################################
# Internet Gateway + public routing
###############################################################################
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(local.tags, { Name = "us-aws-usw2-prod-igw" })
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
tags = merge(local.tags, { Name = "us-aws-usw2-prod-rt-public" })
}
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.main.id
}
resource "aws_route_table_association" "public" {
for_each = local.public_subnets
subnet_id = aws_subnet.this[each.key].id
route_table_id = aws_route_table.public.id
}
###############################################################################
# NAT gateways — one per AZ, each living in that AZ's public subnet
###############################################################################
# Map AZ -> the public subnet key in that AZ (e.g. "us-west-2a" => "pub1").
locals {
public_subnet_by_az = { for k, v in local.public_subnets : v.az => k }
}
resource "aws_eip" "nat" {
for_each = toset(local.azs)
domain = "vpc"
tags = merge(local.tags, { Name = "us-aws-usw2-prod-eip-nat-${each.key}" })
}
resource "aws_nat_gateway" "this" {
for_each = toset(local.azs)
allocation_id = aws_eip.nat[each.key].id
subnet_id = aws_subnet.this[local.public_subnet_by_az[each.key]].id
tags = merge(local.tags, { Name = "us-aws-usw2-prod-nat-${each.key}" })
depends_on = [aws_internet_gateway.main]
}
###############################################################################
# Private routing (app + mgmt) — per-AZ route table to that AZ's NAT
###############################################################################
resource "aws_route_table" "private" {
for_each = toset(local.azs)
vpc_id = aws_vpc.main.id
tags = merge(local.tags, { Name = "us-aws-usw2-prod-rt-private-${each.key}" })
}
resource "aws_route" "private_default" {
for_each = toset(local.azs)
route_table_id = aws_route_table.private[each.key].id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[each.key].id
}
resource "aws_route_table_association" "private" {
for_each = local.private_subnets
subnet_id = aws_subnet.this[each.key].id
route_table_id = aws_route_table.private[each.value.az].id
}
###############################################################################
# Data routing — isolated, no default route (VPC-internal only)
###############################################################################
resource "aws_route_table" "data" {
vpc_id = aws_vpc.main.id
tags = merge(local.tags, { Name = "us-aws-usw2-prod-rt-data" })
}
resource "aws_route_table_association" "data" {
for_each = local.data_subnets
subnet_id = aws_subnet.this[each.key].id
route_table_id = aws_route_table.data.id
}
###############################################################################
# Outputs
###############################################################################
output "vpc_id" {
value = aws_vpc.main.id
}
output "subnet_ids" {
description = "Map of subnet name => subnet id"
value = { for k, s in aws_subnet.this : k => s.id }
}
output "subnet_ids_by_tier" {
description = "Subnet ids grouped by tier"
value = {
public = [for k, v in local.public_subnets : aws_subnet.this[k].id]
app = [for k, v in local.subnets : aws_subnet.this[k].id if v.tier == "app"]
data = [for k, v in local.data_subnets : aws_subnet.this[k].id]
mgmt = [for k, v in local.subnets : aws_subnet.this[k].id if v.tier == "mgmt"]
}
}
5. Notes
- IPAM-driven allocation. The CIDRs are hardcoded here so the plan is easy to verify. In production, switch the VPC to draw its
/20from the AWS IPAM pool (see the commented block onaws_vpc.main) so allocation is enforced rather than copied by hand. - NAT cost. Three NAT gateways carry an hourly charge plus per-GB data processing. For non-prod environments, collapse to a single shared NAT to save cost.
- Growth headroom. The spare
/24in each tier (10.1.3.0/24,10.1.7.0/24,10.1.11.0/24,10.1.15.0/24) absorbs a fourth AZ or tier expansion without disturbing the layout. - Next layers. Security groups and NACLs per tier (e.g. data accepts
5432/3306only from the app tier) are the natural follow-on. The same module, re-pointed at a different/14, stamps out the Japan and UK regional VPCs.