How to Build an AWS VPC With Terraform: Public and Private Subnets (2026)
This guide is for engineers who want a production-shaped AWS VPC in Terraform instead of the default VPC or a console click-through. At the end you will have a three-AZ VPC with public subnets for load balancers, private subnets for workloads, a NAT layout you chose on purpose (one per AZ, one shared, or the regional NAT AWS launched in November 2025), a free S3 gateway endpoint, VPC flow logs, outputs other stacks can consume, and a test that proves a private instance reaches the internet and S3. Versions: terraform-aws-modules/vpc 6.7.3 (18 September 2026), AWS provider 6.70.0 (22 October 2026; 6.71.0 followed on 28 October and the ~> 6.70 pin below accepts it) and Terraform 1.11 or later. I checked every block below with terraform validate and a mocked terraform test plan on Terraform 1.12.0 (the current release is 1.17.1); I did not apply it to a live account for this post. You need an AWS account where you can create VPC, EC2, IAM and CloudWatch Logs resources, AWS CLI v2 with credentials, and a budget of about $3.60 a day in us-east-1 while three NAT gateways and their IPs run.
Step 1: Plan the CIDR and subnet layout
The VPC CIDR is the one choice you cannot resize later, so start there. AWS allows a VPC between a /16 and a /28 and keeps five addresses in every subnet. Pick a /16 from RFC 1918 that overlaps nothing you will ever peer with or route to over VPN. This guide uses 10.20.0.0/16.
The layout: three large private /20s for workloads (EKS pods eat IPs quickly), three small public /24s that hold only load balancers and NAT gateways, and the rest of the /16 left free for database or isolated subnets later. Terraform's cidrsubnet does the arithmetic, and terraform console lets you check it before anything exists:
# Terraform 1.12.0, run in an empty directory (no providers needed)
echo '[for i in range(3) : cidrsubnet("10.20.0.0/16", 4, i)]' | terraform console
echo '[for i in range(3) : cidrsubnet("10.20.0.0/16", 8, 48 + i)]' | terraform console
Real output from my machine:
[
"10.20.0.0/20",
"10.20.16.0/20",
"10.20.32.0/20",
]
[
"10.20.48.0/24",
"10.20.49.0/24",
"10.20.50.0/24",
]
| Subnet | AZ | CIDR | Usable IPs |
|---|---|---|---|
| private-a, b, c | first three AZs | 10.20.0.0/20, 10.20.16.0/20, 10.20.32.0/20 | 4,091 each |
| public-a, b, c | first three AZs | 10.20.48.0/24, 10.20.49.0/24, 10.20.50.0/24 | 251 each |
| free | none yet | 10.20.51.0 to 10.20.255.255 | reserved for later tiers |
Step 2: Pin Terraform, the AWS provider and the state backend
Pin everything. Module 6.7.3 requires AWS provider 6.28 or newer, and regional NAT support arrived in provider 6.24 per the provider changelog, so ~> 6.70 covers both. The S3 backend's use_lockfile became generally available in Terraform 1.11, per the 1.11 changelog, which is why that is the floor; the background is in Terraform State in 2026. Create the state bucket (versioned, public access blocked) before you run init.
# versions.tf
terraform {
# S3-native state locking (use_lockfile) went GA in Terraform 1.11.
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.70" # any 6.x from 6.70; the VPC module needs >= 6.28
}
}
# The bucket must exist first; versioning on, public access blocked.
backend "s3" {
bucket = "acme-tfstate-us-east-1" # replace with your bucket
key = "network/prod/vpc.tfstate"
region = "us-east-1"
use_lockfile = true # S3-native lock object; no DynamoDB table needed
encrypt = true
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
ManagedBy = "terraform"
Environment = var.environment
}
}
}
# variables.tf
variable "region" {
type = string
default = "us-east-1"
}
variable "environment" {
type = string
default = "prod"
}
variable "vpc_cidr" {
description = "Primary IPv4 CIDR. Pick a /16 that does not overlap anything you may peer with."
type = string
default = "10.20.0.0/16"
}
variable "nat_mode" {
description = "per_az = one zonal NAT per AZ, single = one shared NAT, regional = one regional NAT, none = no NAT"
type = string
default = "per_az"
validation {
condition = contains(["per_az", "single", "regional", "none"], var.nat_mode)
error_message = "nat_mode must be per_az, single, regional or none."
}
}
variable "create_test_instance" {
description = "Launch a t4g.nano in a private subnet to test egress (Step 8). Destroy it afterwards."
type = bool
default = false
}
Step 3: Create the VPC with three public and three private subnets
The AZ data source filters out opt-in Local Zones and Wavelength Zones, then the module builds the VPC, subnets, internet gateway and route tables. A few settings matter more than they look. map_public_ip_on_launch = false means nothing in a public subnet gets a billable public IPv4 by accident. manage_default_security_group = true adopts the default security group and, with no rules supplied, leaves it denying everything, so a resource launched without a security group cannot talk to anyone. The subnet tags let the AWS Load Balancer Controller find public and internal subnets if EKS lands here later.
# main.tf (part 1)
data "aws_availability_zones" "available" {
state = "available"
# Skip Local Zones and Wavelength Zones, which are opt-in.
filter {
name = "opt-in-status"
values = ["opt-in-not-required"]
}
}
locals {
name = "${var.environment}-vpc"
azs = slice(data.aws_availability_zones.available.names, 0, 3)
# Private: three /20s (4,091 usable IPs each) for workloads and pods.
private_subnets = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 4, i)]
# Public: three /24s (251 usable IPs each) for load balancers and NAT only.
public_subnets = [for i, az in local.azs : cidrsubnet(var.vpc_cidr, 8, 48 + i)]
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "6.7.3"
name = local.name
cidr = var.vpc_cidr
azs = local.azs
private_subnets = local.private_subnets
public_subnets = local.public_subnets
# Zonal NAT gateways managed by the module. "regional" is handled in nat.tf.
enable_nat_gateway = contains(["per_az", "single"], var.nat_mode)
single_nat_gateway = contains(["single", "regional"], var.nat_mode) # one private route table
one_nat_gateway_per_az = var.nat_mode == "per_az"
enable_dns_hostnames = true
enable_dns_support = true
# Nothing in a public subnet gets a public IPv4 unless you ask for one.
map_public_ip_on_launch = false
# Adopt the default security group and strip its rules (deny all).
manage_default_security_group = true
public_subnet_tags = {
Tier = "public"
"kubernetes.io/role/elb" = "1"
}
private_subnet_tags = {
Tier = "private"
"kubernetes.io/role/internal-elb" = "1"
}
}
Do not use the module's built-in enable_flow_log input. The module README marks root-module flow logs as deprecated in v6 and due for removal in v7.0.0; Step 6 uses the standalone submodule instead.
Step 4: Choose a NAT layout: per AZ, single or regional
The module has three NAT switches, and the README spells out the precedence: single_nat_gateway wins over one_nat_gateway_per_az, and with neither set you get one NAT per private subnet. The nat_mode variable above turns that into one decision. The zonal NAT lives in one AZ, and AWS recommends one per AZ so a zone failure does not take every zone's egress with it. The regional NAT is one gateway that expands into any AZ where it sees an ENI (allow up to 60 minutes for a new AZ), needs no public subnet, and is billed per active AZ.
| nat_mode | What gets created | Private route tables | Fixed cost per month, us-east-1 |
|---|---|---|---|
| per_az | 3 zonal NATs, 3 Elastic IPs | 3 | $98.55 NAT + $10.95 IPv4 |
| single | 1 zonal NAT, 1 Elastic IP | 1 | $32.85 NAT + $3.65 IPv4, plus $0.01/GB each way across AZs |
| regional | 1 regional NAT, AWS-managed IPs | 1 | $0.045 per active AZ-hour ($98.55 for 3 AZs) + IPv4 |
| none | nothing | 3 | $0 |
Prices are $0.045 per NAT-hour and $0.005 per public IPv4-hour from the VPC pricing page at 730 hours, with $0.045 per GB processed on top in every NAT mode. The module 6.7.3 does not create regional NATs yet (its aws_nat_gateway has no availability_mode), so nat.tf adds one directly and points the single private route table at it:
# nat.tf
# Regional NAT gateway (AWS provider >= 6.24). The VPC module 6.7.3 does not
# create these, so we add it ourselves and point the single private route
# table at it.
resource "aws_nat_gateway" "regional" {
count = var.nat_mode == "regional" ? 1 : 0
vpc_id = module.vpc.vpc_id
availability_mode = "regional" # auto mode: AWS adds AZs and EIPs as ENIs appear
tags = { Name = "${local.name}-regional-nat" }
# The internet gateway must be attached before the NAT can come up.
depends_on = [module.vpc]
}
resource "aws_route" "private_to_regional_nat" {
count = var.nat_mode == "regional" ? 1 : 0
route_table_id = module.vpc.private_route_table_ids[0]
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.regional[0].id
}
The argument names (availability_mode, vpc_id, and no subnet_id in regional mode) come from the provider's aws_nat_gateway docs at v6.70.0. Leaving out availability_zone_address blocks keeps it in auto mode; adding them later forces a replacement. I run single in dev and per_az or regional in production; the trade-offs are in AWS VPC Explained.
Step 5: Add the free S3 gateway endpoint
A gateway endpoint for S3 costs nothing and adds a prefix-list route to every route table you associate, so S3 traffic stops paying $0.045 per GB at the NAT. It also covers most of an ECR image pull, because ECR stores layers in S3. The module's vpc-endpoints submodule creates it; no security group is needed for the Gateway type.
# main.tf (part 2)
module "vpc_endpoints" {
source = "terraform-aws-modules/vpc/aws//modules/vpc-endpoints"
version = "6.7.3"
vpc_id = module.vpc.vpc_id
endpoints = {
s3 = {
service = "s3"
service_type = "Gateway" # free; adds a prefix-list route to each table
route_table_ids = concat(
module.vpc.private_route_table_ids,
module.vpc.public_route_table_ids,
)
tags = { Name = "${local.name}-s3" }
}
}
}
Add a dynamodb entry with the same shape if anything here talks to DynamoDB. Interface endpoints (ECR API, STS, Secrets Manager) cost $0.01 per AZ-hour each, so add them when traffic or policy justifies it, not by default.
Step 6: Turn on VPC flow logs
Use the standalone flow-log submodule, added in 6.3.0. By default it creates the CloudWatch log group (90-day retention unless you override it) and an IAM role that the flow log service assumes. Flow logs typically reach CloudWatch Logs in about 5 minutes and S3 in about 10. Ingestion costs $0.50 per GB into the standard log class and $0.25 per GB to S3 for the first 10 TB in us-east-1, per the CloudWatch price list, so busy VPCs often send them to S3 instead.
# main.tf (part 3)
module "flow_log" {
source = "terraform-aws-modules/vpc/aws//modules/flow-log"
version = "6.7.3"
name = local.name
vpc_id = module.vpc.vpc_id
traffic_type = "ALL"
max_aggregation_interval = 60 # seconds; Nitro ENIs aggregate at 60s or less anyway
cloudwatch_log_group_retention_in_days = 30
}
To send them to S3 instead, set log_destination_type = "s3" and log_destination to a bucket ARN, as the module's own example does.
Step 7: Add outputs, then plan and apply
Outputs are the contract with every stack that builds on this network: EKS, RDS, Lambda and the security groups all need subnet and route table IDs.
# outputs.tf
output "vpc_id" {
value = module.vpc.vpc_id
}
output "vpc_cidr_block" {
value = module.vpc.vpc_cidr_block
}
output "azs" {
value = module.vpc.azs
}
output "public_subnet_ids" {
value = module.vpc.public_subnets
}
output "private_subnet_ids" {
value = module.vpc.private_subnets
}
output "private_route_table_ids" {
value = module.vpc.private_route_table_ids
}
output "nat_public_ips" {
description = "Zonal NAT EIPs. Regional NAT IPs are in aws_nat_gateway.regional."
value = module.vpc.nat_public_ips
}
output "s3_endpoint_id" {
value = module.vpc_endpoints.endpoints["s3"].id
}
output "flow_log_group" {
value = module.flow_log.cloudwatch_log_group_name
}
output "test_instance_id" {
value = try(aws_instance.test[0].id, null)
}
terraform init
terraform fmt -check && terraform validate
terraform plan -out vpc.tfplan # default nat_mode = per_az
terraform apply vpc.tfplan
In my mocked plan run, per_az planned 37 resources (the VPC, internet gateway and three adopted defaults, six subnets, three NATs and EIPs, four route tables with their routes and associations, the endpoint and five flow log resources), single planned 29 and regional 28. If your plan shows a different count, read the diff before applying. Before applying for real, you can prove each NAT mode wires up as intended without AWS credentials, using terraform test with a mock provider (more in How to Test Terraform):
# tests/nat_modes.tftest.hcl (excerpt; full file has a run per nat_mode)
mock_provider "aws" {
mock_data "aws_iam_policy_document" {
defaults = {
json = "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
}
}
override_data {
target = data.aws_availability_zones.available
values = {
names = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
}
override_data {
target = data.aws_ssm_parameter.al2023_arm64
values = {
insecure_value = "ami-0123456789abcdef0"
}
}
}
run "per_az" {
command = plan
variables {
nat_mode = "per_az"
}
assert {
condition = length(module.vpc.natgw_ids) == 3
error_message = "expected 3 NAT gateways"
}
}
run "regional" {
command = plan
variables {
nat_mode = "regional"
}
assert {
condition = length(module.vpc.natgw_ids) == 0 && length(aws_nat_gateway.regional) == 1
error_message = "expected only the regional NAT"
}
}
My four-run version ended with Success! 4 passed, 0 failed. The aws_iam_policy_document mock matters: without it the flow-log role gets a random string as its trust policy and the plan fails with "assume_role_policy" contains an invalid JSON policy.
Step 8: Test connectivity from a private subnet
A plan proves wiring, not packets. Launch a throwaway Graviton instance in a private subnet with no SSH and no public IP, reach it through Systems Manager, and curl out. The SSM agent itself needs working egress to register, so the instance coming online is the first test.
# test.tf
data "aws_ssm_parameter" "al2023_arm64" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64"
}
resource "aws_iam_role" "ssm_test" {
count = var.create_test_instance ? 1 : 0
name_prefix = "vpc-test-ssm-"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
count = var.create_test_instance ? 1 : 0
role = aws_iam_role.ssm_test[0].name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ssm_test" {
count = var.create_test_instance ? 1 : 0
name_prefix = "vpc-test-ssm-"
role = aws_iam_role.ssm_test[0].name
}
resource "aws_security_group" "test" {
count = var.create_test_instance ? 1 : 0
name_prefix = "vpc-test-"
description = "Connectivity test: HTTPS egress only"
vpc_id = module.vpc.vpc_id
}
resource "aws_vpc_security_group_egress_rule" "test_https" {
count = var.create_test_instance ? 1 : 0
security_group_id = aws_security_group.test[0].id
description = "HTTPS to anywhere (SSM, S3, checkip)"
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 443
to_port = 443
}
resource "aws_instance" "test" {
count = var.create_test_instance ? 1 : 0
ami = data.aws_ssm_parameter.al2023_arm64.insecure_value
instance_type = "t4g.nano"
subnet_id = module.vpc.private_subnets[0]
vpc_security_group_ids = [aws_security_group.test[0].id]
iam_instance_profile = aws_iam_instance_profile.ssm_test[0].name
metadata_options {
http_tokens = "required" # IMDSv2 only
}
tags = { Name = "${local.name}-connectivity-test" }
}
terraform apply -var create_test_instance=true
INSTANCE_ID=$(terraform output -raw test_instance_id)
VPC_ID=$(terraform output -raw vpc_id)
# 1. Agent registration (repeat until it prints Online, usually a few minutes)
aws ssm describe-instance-information \
--filters "Key=InstanceIds,Values=$INSTANCE_ID" \
--query "InstanceInformationList[].PingStatus" --output text
# 2. Egress through the NAT, and S3 through the gateway endpoint
CMD_ID=$(aws ssm send-command \
--instance-ids "$INSTANCE_ID" \
--document-name "AWS-RunShellScript" \
--parameters '{"commands":["curl -s https://checkip.amazonaws.com","curl -sS -o /dev/null -w %{http_code} https://s3.us-east-1.amazonaws.com/"]}' \
--query "Command.CommandId" --output text)
aws ssm wait command-executed --command-id "$CMD_ID" --instance-id "$INSTANCE_ID"
aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" \
--query "StandardOutputContent" --output text
# 3. The S3 prefix-list routes the endpoint added (one line per route table)
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VPC_ID" \
--query "RouteTables[].Routes[?DestinationPrefixListId].[DestinationPrefixListId,GatewayId]" \
--output text
# 4. Flow logs: active and delivering, then the most recent HTTPS records
aws ec2 describe-flow-logs --filter "Name=resource-id,Values=$VPC_ID" \
--query "FlowLogs[].[FlowLogStatus,DeliverLogsStatus]" --output text
aws logs tail "$(terraform output -raw flow_log_group)" --since 15m | grep ' 443 ' | head -5
What good looks like: the first curl prints a public IP that matches one of terraform output nat_public_ips (with per_az, the NAT in the instance's AZ). The second prints an HTTP status; an unsigned request to the S3 regional endpoint returned 307 when I ran it from my own machine, and any status proves the TCP and TLS path. The route query prints a pl- and vpce- pair for every route table, the flow log query prints ACTIVE SUCCESS, and the log lines follow the default version 2 format (account, interface, source and destination address and port, protocol, packets, bytes, start, end, ACCEPT or REJECT). Then remove the test resources with terraform apply -var create_test_instance=false.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
no available releases match the given constraints ~> 5.0, >= 6.28.0 on init |
Root module still pins AWS provider 5.x; module 6.x needs 6.28 or newer | Set version = "~> 6.70", run terraform init -upgrade, review the plan for provider v6 changes |
An argument named "availability_mode" is not expected here |
AWS provider older than 6.24, which added regional NAT | Upgrade the provider and commit the new .terraform.lock.hcl |
AddressLimitExceeded: The maximum number of addresses has been reached. |
Default quota of 5 Elastic IPs per Region; per_az needs 3 | Release idle EIPs or request an increase (quota L-0263D0A3); or use single or regional |
InvalidSubnet.Conflict: The CIDR '10.20.0.0/20' conflicts with another subnet |
Overlapping CIDRs, often after editing the cidrsubnet math or AZ count | Recheck in terraform console; add new tiers in free space, never renumber live subnets |
| curl from the test instance hangs | Private route table lacks the NAT route, security group lacks 443 egress, or a custom NACL blocks return ports 1024 to 65535 | Inspect the subnet's routes, then run Reachability Analyzer ($0.10 per analysis) |
InvalidInstanceId from aws ssm send-command |
SSM agent never registered: no egress, missing instance profile, or still booting | Wait for PingStatus Online; if it never comes, fix the NAT route or role first |
| Flow log group is empty | Normal delivery lag, or the delivery role cannot write | Wait 5 to 10 minutes; check DeliverLogsStatus and DeliverLogsErrorMessage in describe-flow-logs |
DependencyViolation on terraform destroy |
ENIs Terraform did not create (Kubernetes load balancers, Lambda, hand-made endpoints) still sit in the subnets | List ENIs with describe-network-interfaces --filters Name=vpc-id,... and delete their owners first |
| Every plan removes tags that Kubernetes or a load balancer controller added | A Terraform limitation the module README documents: lifecycle must be literal |
Own those tags in Terraform (default_tags or subnet tag inputs), or skip them with the provider's ignore_tags block |
Error acquiring the state lock |
Another run holds the S3 lock file, or a crashed run left it | Follow the state lock guide; only force-unlock when you know no run is active |
FAQ
Should I use the terraform-aws-modules VPC module or plain resources?
Use the module unless you have a reason not to: this layout is 37 resources, and the module has absorbed years of edge cases in route tables and NAT counts. Write plain resources when you need something it does not model, such as the regional NAT in Step 4, and keep those next to the module rather than forking it.
How many NAT gateways does a Terraform VPC need?
One per AZ in production, one in dev and test. With enable_nat_gateway = true, set one_nat_gateway_per_az = true and single_nat_gateway = false for the first, or single_nat_gateway = true for the second; a regional NAT gives per-AZ behaviour with one ID at the same per-AZ hourly price.
How do I add IPv6 to this VPC?
Set enable_ipv6 = true to request an Amazon-provided /56, then public_subnet_ipv6_prefixes = [0, 1, 2] and private_subnet_ipv6_prefixes = [3, 4, 5] to carve /64s. The module creates an egress-only internet gateway by default (create_egress_only_igw = true) and routes private IPv6 egress to it, which is free and skips NAT processing charges.
Can I bring an existing VPC under this code?
Yes, with import blocks that target the module's addresses, such as module.vpc.aws_vpc.this[0] and module.vpc.aws_subnet.private[0]. Run a plan until it shows no changes before you apply anything; the import guide walks through the workflow.
In production, the first thing I check on any VPC is the private route tables: every one should have exactly one default route, to a NAT in the same AZ (or the regional NAT), plus the S3 prefix-list route. The second is describe-addresses for Elastic IPs attached to nothing. Both take a minute and catch most of the mistakes I would otherwise find on the bill. Official references: the terraform-aws-modules/vpc repository, the provider's aws_nat_gateway docs, and the AWS guides to NAT gateways, regional NAT, gateway endpoints and flow log records.
Related: Terraform State in 2026: S3 Locking Replaced DynamoDB, What Else Changed, How to Test Terraform: terraform test, TFLint and Checkov (2026) and How to Import Existing Resources into Terraform and OpenTofu (2026).
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Import Existing Resources into Terraform and OpenTofu (2026)
Step-by-step guide to importing existing cloud resources into Terraform 1.16 or OpenTofu 1.13: import blocks with id or identity, -generate-config-out, terraform query for bulk discovery, for_each imports, cleaning generated HCL, proving a no-op plan, and fixing common import errors.
13 minOct 31, 2026How to Build a CI/CD Pipeline With GitHub Actions: Step-by-Step (2026)
Build a CI/CD pipeline with GitHub Actions from scratch: lint and test every pull request, Docker Buildx with the GHA cache, push to GHCR, staging and production environments with required reviewers, deploy by digest over SSH, concurrency, SHA-pinned actions and fixes for common errors.
18 minOct 31, 2026How to Build Multi-Arch Docker Images with Buildx (amd64 + arm64, 2026)
Build one Docker image for linux/amd64 and linux/arm64 with docker buildx: platform-aware Dockerfiles, QEMU vs native GitHub arm64 runners, manifest lists, gha and registry caching, a full GitHub Actions workflow, and fixes for exec format error.
12 min