Every cloud platform supports Terraform, and Terraform is the de facto standard for production VPC provisioning. The path from "we want a VPC with these subnets" to working Terraform code is straightforward but has plenty of places to introduce errors: typos in CIDRs, off-by-one alignment mistakes, forgetting AZ assignments, and so on.
This article walks through the patterns that turn a designed subnet allocation into reliable Terraform, the common mistakes to avoid, and how to generate the boilerplate automatically. Our IaC Export tool generates Terraform from a VLSM design in one click — but understanding what it produces is essential for production use.
The minimum-viable AWS VPC in Terraform
A bare-bones VPC with three subnets across three AZs:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "prod-vpc" }
}
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.0.0/20"
availability_zone = "us-east-1a"
tags = { Name = "prod-private-a", Tier = "private" }
}
resource "aws_subnet" "private_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.16.0/20"
availability_zone = "us-east-1b"
tags = { Name = "prod-private-b", Tier = "private" }
}
resource "aws_subnet" "private_c" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.32.0/20"
availability_zone = "us-east-1c"
tags = { Name = "prod-private-c", Tier = "private" }
}
This works, but it does not scale. For a real production VPC with web/app/db tiers across three AZs, you would have 9 subnet resources, plus route tables, internet/NAT gateways, security groups, and route table associations. Inlining all of that is unmaintainable.
The modular approach
Production Terraform for VPCs uses a few patterns to stay clean:
Pattern 1: cidrsubnet() for derived blocks
Terraform has a built-in function, cidrsubnet(), that derives child CIDRs from a parent. Avoids hardcoding CIDR strings.
variable "vpc_cidr" { default = "10.0.0.0/16" }
locals {
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
# 9 subnets: 3 tiers × 3 AZs, each /20
subnet_cidrs = {
private_a = cidrsubnet(var.vpc_cidr, 4, 0) # 10.0.0.0/20
private_b = cidrsubnet(var.vpc_cidr, 4, 1) # 10.0.16.0/20
private_c = cidrsubnet(var.vpc_cidr, 4, 2) # 10.0.32.0/20
public_a = cidrsubnet(var.vpc_cidr, 4, 3) # 10.0.48.0/20
public_b = cidrsubnet(var.vpc_cidr, 4, 4) # 10.0.64.0/20
public_c = cidrsubnet(var.vpc_cidr, 4, 5) # 10.0.80.0/20
db_a = cidrsubnet(var.vpc_cidr, 8, 96) # 10.0.96.0/24
db_b = cidrsubnet(var.vpc_cidr, 8, 97) # 10.0.97.0/24
db_c = cidrsubnet(var.vpc_cidr, 8, 98) # 10.0.98.0/24
}
}
The function signature is cidrsubnet(prefix, newbits, netnum). It extends the prefix by newbits bits and uses netnum for those bits. Read more in the Terraform documentation.
Pattern 2: for_each over a subnet map
Once you have a map of subnets, create them all with one resource block using for_each:
resource "aws_subnet" "this" {
for_each = local.subnet_cidrs
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = local.azs[index(keys(local.subnet_cidrs), each.key) % 3]
tags = {
Name = "prod-${each.key}"
Tier = split("_", each.key)[0]
}
}
This creates 9 subnet resources from one block. Adding or removing a subnet is one line in the map.
Pattern 3: Use a community module
For most teams, writing VPC Terraform from scratch is not worth it. The terraform-aws-modules/vpc/aws module has been the de facto standard since 2018 and handles every edge case (NAT gateways, VPC endpoints, flow logs, IPv6, transit gateway attachments).
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.0.0/20", "10.0.16.0/20", "10.0.32.0/20"]
public_subnets = ["10.0.48.0/20", "10.0.64.0/20", "10.0.80.0/20"]
database_subnets = ["10.0.96.0/24", "10.0.97.0/24", "10.0.98.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # one per AZ for HA
enable_vpn_gateway = false
enable_dns_hostnames = true
}
Most production AWS deployments should use this module unless there is a specific reason not to. The custom Terraform above is useful for understanding what is happening, but in practice "use the module" is the right answer.
Common mistakes
Hardcoded CIDRs that drift from documentation
If your subnet CIDRs are hardcoded in Terraform and also in a Confluence page, the two will drift. Use Terraform outputs to render the actual CIDRs into reports/docs, not vice versa.
Forgetting AZ count for cidrsubnet()
If you use cidrsubnet(var.vpc_cidr, 4, count.index) with 9 subnets, you carve the /16 into 16 chunks (/20 each). If you later add a 10th subnet with the same pattern, no problem. But if you change 4 to 5 (carving into /21), every existing subnet's CIDR changes — and Terraform will try to delete and recreate them. Audit any change to newbits.
Off-by-one in netnum
cidrsubnet("10.0.0.0/16", 4, 0) gives 10.0.0.0/20. The first subnet is netnum=0, not netnum=1. Documenting this convention in comments prevents confusion later.
Not reserving address space for growth
If you allocate the full /16 across 16 /20 subnets at provisioning time, there is no room to grow. Use 9-12 subnets out of the 16 possible /20s, leaving the rest reserved.
Cross-AZ database subnets that are too small
RDS Multi-AZ requires a DB subnet group with at least 2 AZs. Each subnet needs at least the minimum size for your instance class (which AWS sets at 8+ IPs for most engines). A /28 is technically usable but very tight; /24 is the practical minimum for production databases.
Beyond AWS: Azure and GCP patterns
Azure's Terraform provider uses azurerm_virtual_network and azurerm_subnet. The main differences from AWS:
- Some subnets need specific names hardcoded by the platform:
GatewaySubnet,AzureBastionSubnet,AzureFirewallSubnet. Get the name wrong and the platform refuses to recognize the subnet. - NSGs are attached at the subnet level (not per-instance like AWS security groups).
- VNet peering is a separate resource and is one-directional — you need both ends configured.
See our Azure VNet subnet sizing guide for the full pattern.
GCP uses google_compute_network and google_compute_subnetwork. The main differences:
- Subnets are regional (one per region), not zonal. Simpler than AWS multi-AZ patterns.
- Auto-mode VPCs come with pre-defined subnets in every region. Always use custom-mode for production.
- Subnet expansion (growing an existing subnet's CIDR) is supported in GCP without downtime. AWS and Azure require recreation.
Generating Terraform automatically
Our IaC Export tool turns a VLSM design into ready-to-use Terraform for AWS, Azure, or GCP. The workflow:
- Design your subnet allocation in the VLSM Designer.
- Click "Export to IaC".
- Choose your provider (AWS, Azure, GCP).
- Pick the format: terraform-aws-modules style, raw resources, CloudFormation, Pulumi, or Ansible.
- Copy the generated code into your repo.
The generator handles the boilerplate (route tables, NAT gateways, AZ assignment, tagging) so you can focus on the parts that vary by your organization.
Key takeaways
- Use cidrsubnet() in Terraform to derive child CIDRs from a parent VPC CIDR — avoids hardcoded magic numbers.
- Use for_each to create many subnets from one resource block.
- For most AWS deployments, use the terraform-aws-modules/vpc/aws community module instead of hand-rolling resources.
- Reserve unallocated CIDR space for future subnets; do not provision the entire VPC at day one.
- Use our IaC Export to generate provider-specific Terraform from a designed VLSM allocation.