Skip to content
Infrastructure as Code iac cloud 4 min read

Recipe: AWS VPC

Almost every AWS workload starts with a Virtual Private Cloud—an isolated network where your instances, load balancers, and managed services live. A production-grade VPC spreads subnets across multiple Availability Zones, separates public-facing resources from private ones, and routes egress through managed gateways. This recipe builds exactly that: a multi-AZ VPC with public and private subnets, an internet gateway, NAT gateways, route tables, and clean outputs—driven entirely by locals and for_each so adding a third AZ is a one-line change. Everything here works identically on Terraform 1.5+ and OpenTofu.

Designing the address space

Before any HCL, decide on a CIDR plan. We give the VPC a /16 (65,536 addresses) and carve /24 subnets out of it with the built-in cidrsubnet function. Encoding the layout in locals keeps subnet math in one place and lets the rest of the configuration stay declarative.

locals {
  vpc_cidr = "10.0.0.0/16"
  azs      = ["us-east-1a", "us-east-1b", "us-east-1c"]

  # Map each AZ to a deterministic /24, offset so public and
  # private ranges never overlap.
  public_subnets = {
    for idx, az in local.azs : az => cidrsubnet(local.vpc_cidr, 8, idx)
  }

  private_subnets = {
    for idx, az in local.azs : az => cidrsubnet(local.vpc_cidr, 8, idx + 100)
  }

  tags = {
    Project   = "devcraftly"
    ManagedBy = "terraform"
  }
}

The two for expressions produce maps keyed by AZ name—for example { "us-east-1a" = "10.0.0.0/24", ... }. Keying by AZ rather than by integer index is what makes for_each later resistant to churn: removing an AZ destroys only that AZ’s resources.

The VPC and internet gateway

The VPC itself is a single resource. Enabling DNS support and hostnames is required for most managed services (RDS, EKS, private endpoints) to resolve correctly.

resource "aws_vpc" "main" {
  cidr_block           = local.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(local.tags, { Name = "devcraftly-vpc" })
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = merge(local.tags, { Name = "devcraftly-igw" })
}

Subnets across Availability Zones

Both subnet tiers use for_each over the maps from locals. Public subnets set map_public_ip_on_launch so instances receive a routable address; private subnets do not.

resource "aws_subnet" "public" {
  for_each = local.public_subnets

  vpc_id                  = aws_vpc.main.id
  cidr_block              = each.value
  availability_zone       = each.key
  map_public_ip_on_launch = true

  tags = merge(local.tags, { Name = "public-${each.key}", Tier = "public" })
}

resource "aws_subnet" "private" {
  for_each = local.private_subnets

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value
  availability_zone = each.key

  tags = merge(local.tags, { Name = "private-${each.key}", Tier = "private" })
}

NAT gateways for private egress

Private subnets need outbound internet access (package mirrors, API calls) without being reachable from outside. A NAT gateway provides that, and it lives in a public subnet with an Elastic IP. For high availability we deploy one NAT gateway per AZ; for cost-sensitive environments a single shared NAT is a valid trade-off.

resource "aws_eip" "nat" {
  for_each = aws_subnet.public
  domain   = "vpc"
  tags     = merge(local.tags, { Name = "nat-eip-${each.key}" })
}

resource "aws_nat_gateway" "main" {
  for_each = aws_subnet.public

  allocation_id = aws_eip.nat[each.key].id
  subnet_id     = each.value.id
  tags          = merge(local.tags, { Name = "nat-${each.key}" })

  depends_on = [aws_internet_gateway.main]
}

NAT gateways and their Elastic IPs are billed hourly plus per-GB processed, even when idle. One-NAT-per-AZ multiplies that cost—use for_each over a single public subnet (or a var.single_nat_gateway toggle) for dev environments.

Route tables

The public route table sends 0.0.0.0/0 to the internet gateway and is shared by all public subnets. Each private subnet gets its own route table pointing at the NAT gateway in its own AZ, so traffic never crosses zones.

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
  tags = merge(local.tags, { Name = "public-rt" })
}

resource "aws_route_table_association" "public" {
  for_each       = aws_subnet.public
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  for_each = aws_subnet.private
  vpc_id   = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main[each.key].id
  }

  tags = merge(local.tags, { Name = "private-rt-${each.key}" })
}

resource "aws_route_table_association" "private" {
  for_each       = aws_subnet.private
  subnet_id      = each.value.id
  route_table_id = aws_route_table.private[each.key].id
}

Outputs

Expose the IDs downstream modules need—the VPC ID and lists of subnet IDs by tier.

output "vpc_id" {
  value = aws_vpc.main.id
}

output "public_subnet_ids" {
  value = [for s in aws_subnet.public : s.id]
}

output "private_subnet_ids" {
  value = [for s in aws_subnet.private : s.id]
}

Applying the recipe

Initialize, review, and apply:

terraform init
terraform plan
terraform apply

Output:

Plan: 21 to add, 0 to change, 0 to destroy.

aws_vpc.main: Creation complete after 2s [id=vpc-0a1b2c3d4e5f60718]
aws_internet_gateway.main: Creation complete after 1s [id=igw-0fa9...]
aws_subnet.public["us-east-1a"]: Creation complete after 1s [id=subnet-0c1...]
aws_nat_gateway.main["us-east-1b"]: Still creating... [1m20s elapsed]
aws_nat_gateway.main["us-east-1b"]: Creation complete after 1m31s [id=nat-08d...]

Apply complete! Resources: 21 added, 0 changed, 0 destroyed.

Outputs:

private_subnet_ids = ["subnet-0aa...", "subnet-0bb...", "subnet-0cc..."]
public_subnet_ids  = ["subnet-0c1...", "subnet-0c2...", "subnet-0c3..."]
vpc_id             = "vpc-0a1b2c3d4e5f60718"

NAT gateways dominate the apply time; everything else provisions in seconds.

Best Practices

  • Centralize CIDR math in locals with cidrsubnet so subnet ranges are deterministic and never overlap by accident.
  • Drive subnets, NAT gateways, and route tables with for_each keyed by AZ—scaling to another zone is a single addition to local.azs.
  • Keep one route table (and ideally one NAT gateway) per private AZ so egress stays in-zone and an AZ outage is isolated.
  • Enable enable_dns_support and enable_dns_hostnames; many managed services silently fail to resolve without them.
  • Gate per-AZ NAT gateways behind a variable so non-production stacks can fall back to a single NAT and cut cost.
  • Output subnet IDs as tier-specific lists so consuming modules (EC2, EKS, RDS) never hard-code network details.
Last updated June 14, 2026
Was this helpful?