Subnetcalculator

Terraform VPC Subnet Calculator

Visually plan public, private, and database subnets across availability zones, then export a ready-to-apply main.tf with the cidrsubnet() math already worked out.

Terraform VPC Subnet Visualizer

Total IPs in VPC: 65,536

Subnet tiers

Subnets planned
6
VPC utilization
13.5%
Unallocated IPs
56,704

Address space map

Tap or click a block to see its CIDR.

Subnet layout

Usable IPs already subtract AWS's 5 reserved addresses per subnet (network, VPC router, DNS, future-use, and broadcast).

CIDR Tier AZ Usable IPs
10.0.32.0/24 Public a 251
10.0.33.0/24 Public b 251
10.0.0.0/20 Private a 4,091
10.0.16.0/20 Private b 4,091
10.0.34.0/26 Database a 59
10.0.34.64/26 Database b 59

main.tf

variable "vpc_cidr" {
  description = "Primary CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

locals {
  subnet_tiers = {
    public = {
      newbits       = 8
      map_public_ip = true
    }
    private = {
      newbits       = 4
      map_public_ip = false
    }
    database = {
      newbits       = 10
      map_public_ip = false
    }
  }
}

data "aws_availability_zones" "available" {
  state = "available"
}

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

  tags = {
    Name = "main-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "main-igw"
  }
}

resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, local.subnet_tiers.public.newbits, 32)
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = local.subnet_tiers.public.map_public_ip

  tags = {
    Name = "public-a"
    Tier = "public"
  }
}

resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, local.subnet_tiers.public.newbits, 33)
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = local.subnet_tiers.public.map_public_ip

  tags = {
    Name = "public-b"
    Tier = "public"
  }
}

resource "aws_subnet" "private_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, local.subnet_tiers.private.newbits, 0)
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = local.subnet_tiers.private.map_public_ip

  tags = {
    Name = "private-a"
    Tier = "private"
  }
}

resource "aws_subnet" "private_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, local.subnet_tiers.private.newbits, 1)
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = local.subnet_tiers.private.map_public_ip

  tags = {
    Name = "private-b"
    Tier = "private"
  }
}

resource "aws_subnet" "database_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, local.subnet_tiers.database.newbits, 136)
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = local.subnet_tiers.database.map_public_ip

  tags = {
    Name = "database-a"
    Tier = "database"
  }
}

resource "aws_subnet" "database_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, local.subnet_tiers.database.newbits, 137)
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = local.subnet_tiers.database.map_public_ip

  tags = {
    Name = "database-b"
    Tier = "database"
  }
}

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 = {
    Name = "public-rt"
  }
}

resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_b" {
  subnet_id      = aws_subnet.public_b.id
  route_table_id = aws_route_table.public.id
}

# NAT Gateway(s) — commented out. Each NAT gateway costs roughly $0.045/hr
# plus $0.045/GB processed (us-east-1 pricing), so one per AZ multiplies
# that by 2. That's the recommended production layout (keeps
# each AZ's egress path self-contained), but a single shared NAT gateway in
# one public subnet is the cheaper option if cross-AZ egress cost/latency
# is acceptable for your workload.
# resource "aws_eip" "nat_a" {
#   domain = "vpc"
# }
#
# resource "aws_nat_gateway" "nat_a" {
#   allocation_id = aws_eip.nat_a.id
#   subnet_id     = aws_subnet.public_a.id
#
#   tags = {
#     Name = "nat-a"
#   }
# }

# resource "aws_eip" "nat_b" {
#   domain = "vpc"
# }
#
# resource "aws_nat_gateway" "nat_b" {
#   allocation_id = aws_eip.nat_b.id
#   subnet_id     = aws_subnet.public_b.id
#
#   tags = {
#     Name = "nat-b"
#   }
# }

How the cidrsubnet() math works

Your VPC is 10.0.0.0/16 — a /16, 65,536 addresses. Terraform's cidrsubnet(prefix, newbits, netnum) borrows newbits bits from the host portion of prefix to carve out a smaller block, and netnum picks which one of the resulting blocks to use (0-indexed).

For the Public tier's /24 subnets: newbits = 24 - 16 = 8, so there are 2^8 = 256 possible /24 blocks inside 10.0.0.0/16. cidrsubnet(var.vpc_cidr, 8, 32) evaluates to 10.0.32.0/24 — netnum 32 selects that block. The next AZ's Public subnet just increments netnum: cidrsubnet(var.vpc_cidr, 8, 33) gives 10.0.33.0/24.

Planning VPC layouts across multiple environments or accounts?

💾 Save this address plan

Also see: general subnet calculator · AWS subnet calculator

Planning VPC address space as Terraform code, not as a diagram

A VPC's address space is usually sketched once — a diagram with a few colored boxes for public, private, and database tiers — and then translated into Terraform by hand, one cidr_block string at a time. That translation step is where most VPC-layout bugs actually originate: a copy-pasted octet that overlaps an existing subnet, a tier sized for today's AZ count that silently breaks when a fourth AZ gets added, or a netnum picked by trial and error instead of computed. The fix isn't a better diagram — it's generating the diagram and the Terraform from the same source of truth, which is what this tool does: the address-space map and the main.tf below it are two views of the exact same allocation.

cidrsubnet(prefix, newbits, netnum) is the mechanism that keeps both views in sync. Given a parent CIDR, it borrows newbits bits to create a smaller block and netnum to select which one — so a VPC's entire subnet layout can be expressed as a handful of function calls derived from one variable "vpc_cidr", instead of a wall of independently hardcoded strings that have to be kept consistent by hand.

The newbits mistakes that actually break applies

  • Same newbits, different tier sizes. Copying a working cidrsubnet() call from a public subnet to a database subnet without updating newbits gives the database tier the same block size as public — usually far more address space than it needs, at the cost of the tier that actually needed room to grow.
  • Reusing a netnum across tiers. netnum is only unique within a fixed newbits value. Two different cidrsubnet() calls with different newbits can legitimately overlap if their netnums weren't chosen with the other tier's allocation in mind — Terraform won't catch this at plan time, only AWS will, at apply time, with a CIDR conflict error.
  • Forgetting the VPC prefix when sizing newbits. newbits is relative to the parent prefix, not an absolute subnet size — newbits = 8 means something completely different against a /16 parent than against a /20 parent. Changing the VPC CIDR without recalculating every tier's newbits is the most common way a working layout breaks after a VPC resize.

Subnet-per-AZ: count vs for_each in practice

Most VPC modules eventually need one subnet resource repeated per availability zone per tier. count is the simplest way to get there — count = var.az_count and an index into data.aws_availability_zones.available.names — but it ties every subnet's identity to its position in the list. Remove AZ index 1 from a 3-AZ layout and Terraform treats every subnet after it as a new resource to create and the old one as a resource to destroy, even though AZ index 2 didn't actually change.

for_each over a map keyed by AZ name (or a stable tier-plus-AZ string such as public-a) avoids that churn — each subnet's Terraform address is tied to its key, not its position, so adding or removing one AZ only plans changes for that AZ. The exported HCL below uses one explicit resource block per tier per AZ rather than for_each, which keeps the generated file readable and copy-pasteable as a starting point — converting it to a for_each module once the layout is stable is a natural next step for teams managing several VPCs from one codebase.

Terraform VPC Subnet FAQ

Why use cidrsubnet() instead of hardcoding each subnet's cidr_block?

Hardcoding cidr_block = "10.0.1.0/24" on every aws_subnet resource works until the VPC CIDR itself needs to change — a new environment, a peered network that collides, an account migration. At that point every hardcoded subnet has to be edited by hand, and it's easy to introduce an overlap or a typo along the way. cidrsubnet(var.vpc_cidr, newbits, netnum) derives every subnet from a single variable, so resizing the VPC or reusing the same module for staging and production is a one-line change instead of a find-and-replace across every subnet block.

What's the difference between newbits and netnum in cidrsubnet()?

newbits is how many additional bits get added to the parent prefix — it decides the subnet's size, not its position. netnum is the index that picks which of the resulting blocks you get, so it decides position, not size. cidrsubnet("10.0.0.0/16", 8, 0) and cidrsubnet("10.0.0.0/16", 8, 1) both create /24 subnets (8 newbits = 24 - 16), but netnum 0 gives 10.0.0.0/24 and netnum 1 gives 10.0.1.0/24. Mixing up the two is the single most common cidrsubnet() mistake — a wrong newbits value silently changes every subnet's size, not just one.

Should I use count or for_each for subnet-per-AZ resources?

for_each, in most cases. With count, removing an AZ from the middle of a list shifts every subsequent subnet's index — Terraform sees that as destroying and recreating subnets it didn't need to touch, not just the one you removed. for_each keyed by AZ name (or by a tier+AZ string) ties each subnet to a stable key, so adding or removing one AZ only touches that AZ's resources. count is fine for a fixed, unlikely-to-change AZ count decided once at launch, but for_each ages better as the VPC's AZ footprint changes over time.

Why does cidrsubnet() sometimes produce subnets that don't look sequential?

Because whoever wrote the netnum values chose to pack larger blocks before smaller ones to keep every subnet aligned to its own size — a private /20 tier allocated before a public /24 tier will claim the lower netnum range, pushing the /24 tier's netnums higher and out of the '0, 1, 2...' order you'd get from a single flat list. This is intentional VLSM packing, not a bug: as long as every generated subnet is non-overlapping and fits inside the parent CIDR, the netnum values don't need to read as a tidy sequence to be correct.

More Network Tools

Free tools for network engineers — no signup, no rate-limit walls.