Hedronite · Cert Lesson · AWS SAP-C02 Organizational Complexity · Thu 2026-10-22

VPC IP address planning for SAP-C02 CIDR sizing, the five reserved addresses, secondary CIDRs, and overlap

A /24 holds 251, not 254, and one overlapping range can quietly drop routes. Check the arithmetic before anything exists.

Lesson Class: Cert (AWS SAP-C02 · Domain 1 Organizational Complexity 26% · Task 1.1)
Tested with: OpenTofu 1.13.1 (no provider) · ipcalc 1.0.3
Lab: lab/plan/main.tf + 7 tfvars · run.sh · out/cert-lab.txt
Source: Amazon VPC, Peering, Transit Gateway and IPAM guides · OpenTofu docs · SAP-C02 exam guide · Brikman
Minus Five
usable = 2^(32 - prefix) - 5 in every subnet.
Grow, Never Resize
Add a secondary block or a new VPC; existing blocks never change size.
Overlap Is Hard
Put overlap in a precondition; leave headroom advice to a check.
The calculator promised 254. The VPC kept five.

The problem

A company runs three virtual private clouds (VPCs, isolated virtual networks in Amazon Web Services, or AWS) for prod, staging and shared services in one AWS Region. An on-premises network reaches them over AWS Direct Connect, a private network link between a data center and AWS. The company has just acquired a team whose VPC uses an address range that sits inside prod's range. Everyone now wants to connect everything, either with VPC peering (a direct link between two VPCs) or by attaching each VPC to a transit gateway (a regional hub that routes between attached networks). Each VPC needs three subnet tiers across three Availability Zones (AZs, separate locations inside a Region). Someone also sized one subnet with a generic calculator that promised 254 hosts.

Which of these plans fail, and which fail silently? The objective is the AWS Certified Solutions Architect - Professional (SAP-C02) Content Domain 1, "Design Solutions for Organizational Complexity" (26% of scored content), Task 1.1, "Architect network connectivity strategies". The knowledge item is "Network segmentation (for example, subnetting, IP addressing, connectivity among VPCs)". The answers come from arithmetic that a machine can check before anything is built.

The idea, in plain words

CIDR (Classless Inter-Domain Routing) notation describes a block of addresses by a starting address and a prefix length. A VPC gets one primary IPv4 block between /16 and /28 and may add secondary blocks under rules. Subnets are carved from those blocks, each inside one AZ, and AWS keeps five addresses in every subnet for itself. Any two networks that must route to each other through peering, a transit gateway or a Direct Connect gateway must not overlap, so the address plan is an organization-wide decision. Because the arithmetic is deterministic, a small plan written in OpenTofu (an infrastructure-as-code tool that reads the HashiCorp configuration language, HCL) can check it with no cloud provider at all. The rule to carry: every range you assign is a promise to every network it may one day route to.

The primary block: prefix length, private ranges and canonical form

A CIDR block pairs an address with a prefix length, the number of leading bits that stay fixed. A /16 fixes 16 bits and leaves 16 free, which gives 65,536 addresses, and every extra prefix bit halves the block. AWS accepts a primary IPv4 block between /16 and /28 (16 addresses). It recommends the private ranges from RFC 1918 (Request for Comments 1918):

RangePrefixExample
10.0.0.0 - 10.255.255.25510/810.0.0.0/16
172.16.0.0 - 172.31.255.255172.16/12172.31.0.0/16
192.168.0.0 - 192.168.255.255192.168/16192.168.0.0/20

You cannot use 0.0.0.0/8, 127.0.0.0/8 (loopback), 169.254.0.0/16 (link-local) or 224.0.0.0/4 (multicast). AWS also advises against 172.17.0.0/16, because some AWS services use it, and the guide names AWS Cloud9 and Amazon SageMaker AI. Finally, command-line and API calls rewrite a CIDR to canonical form, so 100.68.0.18/18 becomes 100.68.0.0/18. The lab turns these rules into four validation blocks, which refuse a bad value before the plan goes further:

variable "vpcs" {
  description = "VPC name => primary IPv4 CIDR"
  type        = map(string)

  validation {
    condition     = alltrue([for c in values(var.vpcs) : can(cidrnetmask(c))])
    error_message = "Every VPC CIDR must be a valid IPv4 CIDR block."
  }

  validation {
    condition = alltrue([for c in values(var.vpcs) :
    tonumber(split("/", c)[1]) >= 16 && tonumber(split("/", c)[1]) <= 28])
    error_message = "A VPC CIDR must be between /16 and /28."
  }

  validation {
    condition     = alltrue([for c in values(var.vpcs) : cidrsubnet(c, 0, 0) == c])
    error_message = "Write each CIDR in canonical form (network address, host bits zero)."
  }

  validation {
    condition = alltrue([for c in values(var.vpcs) : !anytrue([
      for f in ["0.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16", "224.0.0.0/4"] :
      cidrsubnet(format("%s/%s", split("/", c)[0], split("/", f)[1]), 0, 0) == f
    ])])
    error_message = "A VPC CIDR cannot come from 0.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16 or 224.0.0.0/4."
  }
}

The first block accepts only strings that parse as an IPv4 CIDR. The second splits each value at the slash and keeps the prefix between 16 and 28, which is the AWS size rule. The third calls cidrsubnet(c, 0, 0), which adds zero bits and returns the network address, so the result equals the input only when the host bits are zero. The fourth rewrites each candidate address with a forbidden range's prefix length and compares the result to that range. The plan has no rule for 172.17.0.0/16, so it would accept that range. These are the lab's three failures:

### 4 VPC /15
A VPC CIDR must be between /16 and /28.
This was checked by the validation rule at main.tf:13,3-13.
[tofu plan exit 1]

### 5 non-canonical CIDR
Write each CIDR in canonical form (network address, host bits zero).
This was checked by the validation rule at main.tf:19,3-13.
[tofu plan exit 1]

### 6 link-local range
A VPC CIDR cannot come from 0.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16 or
224.0.0.0/4.
This was checked by the validation rule at main.tf:24,3-13.
[tofu plan exit 1]

Each of the three printed "Error: Invalid value for variable" and the offending variable before these lines, trimmed here. The rule locations point at the second, third and fourth validation blocks in main.tf. Each run ended with "Planning failed" and no planned outputs. Step 2 checked the canonical-form example from the guide with ipcalc and with OpenTofu's own function, not with AWS:

Address:	100.68.0.18
Network:	100.68.0.0/18
"100.68.0.0/18"

Symptom: you created a VPC with 100.68.0.18/18 and the console shows 100.68.0.0/18, or two teams' records disagree about a VPC's range.

Cause: AWS silently canonicalizes the block, so a typo in the host bits is accepted and then changed.

Fix: reject non-canonical input in the plan with cidrsubnet(c, 0, 0) == c, as the lab's third validation does, so the typo surfaces before creation.

Subnets and the five reserved addresses

A subnet is a slice of the VPC's range that lives in exactly one AZ: "Each subnet must reside entirely within one Availability Zone and cannot span zones." Three tiers in three AZs therefore means nine subnets. Subnet sizes run from /28 to /16, and subnets in one VPC cannot overlap. In each subnet the first four addresses and the last one are unavailable. For 10.0.0.0/24 the guide lists them as follows:

AddressPurpose
10.0.0.0Network address
10.0.0.1Reserved by AWS for the VPC router
10.0.0.2Reserved by AWS; the DNS (Domain Name System) server is the VPC base plus two, and base plus two of every subnet is also reserved
10.0.0.3Reserved by AWS for future use
10.0.0.255Network broadcast address, reserved because broadcast is not supported

The exception is a range brought to AWS with BYOIP (bring your own IP), where every address is usable. The OpenTofu function cidrsubnet(prefix, newbits, netnum) does the carving. The newbits argument extends the prefix, so /16 plus 4 gives /20, and netnum picks which of the resulting subnets to return. Step 1 compares a generic calculator with the AWS rule:

### 1 generic calculator vs AWS: a /24 and a /28
Network:	10.20.1.0/24
Broadcast:	10.20.1.255
HostMin:	10.20.1.1
HostMax:	10.20.1.254
Hosts/Net:	254
Network:	10.20.1.0/28
Hosts/Net:	14

The calculator subtracts the network and broadcast addresses, as the OpenTofu documentation also explains for its 14-host /28. AWS subtracts five, so the same /24 holds 256 - 5 = 251 usable addresses and the /28 holds 16 - 5 = 11. The plan computes this as pow(2, 32 - prefix) - 5. The next block joins the console check of cidrsubnet with a trimmed excerpt of the good plan's prod subnets:

### 10 tofu console: carve one tier
[
  "10.20.0.0/20",
  "10.20.16.0/20",
  "10.20.32.0/20",
]
"10.20.255.0/24"
"10.20.0.2"

### 3 good plan
          + public-az1  = {
              + cidr   = "10.20.0.0/20"
              + dns    = "10.20.0.2"
              + last   = "10.20.15.255"
              + usable = 4091
          + data-az3    = {
              + cidr   = "10.20.128.0/20"
  + unused_slots = {
      + prod    = 7

The console lines are [for i in range(3) : cidrsubnet("10.20.0.0/16", 4, i)], cidrsubnet("10.20.0.0/16", 8, 255) and cidrhost("10.20.0.0/20", 2). Adding 4 bits to the /16 yields sixteen /20 slots, each with 4,096 addresses and 4,091 usable. Three tiers across three AZs fill nine of the sixteen, so data-az3 sits at 10.20.128.0/20 and seven slots stay free. The dns field is only each subnet's reserved base plus two. Per the guide, the DNS server itself is the VPC base plus two.

A tight case shows the limit. A /24 VPC with newbits 3, four AZs and two tiers needs eight /27 subnets, which fills all eight slots:

  + unused_slots = {
      + edge = 0
Warning: Check block assertion failed
Fewer than 2 unused subnet slots in at least one VPC: {"edge":0}
[tofu plan exit 0]

Each /27 shows usable = 27, which is 32 - 5. The unused_slots output is 2^newbits minus tiers times AZs, so 8 - 8 = 0, and the headroom check asks for at least two. The warning fired and the plan still exited 0, because OpenTofu runs check blocks as the last step of a plan and a failed assertion only warns. With newbits 2 there are only four slots for six subnets:

  + unused_slots = {
      + edge = -2
Call to function "cidrsubnet" failed: prefix extension of 2 does not
accommodate a subnet numbered 4.
[tofu plan exit 1]

Here cidrsubnet itself fails for the fifth and sixth subnets, and that hard error stops the plan where the soft warning could not.

Symptom: a subnet sized for 254 hosts from a calculator runs out of addresses early, or a /28 holds 11 addresses, not 14.

Cause: generic calculators subtract two addresses, while AWS reserves five.

Fix: size with usable = 2^(32 - prefix) - 5, as the plan's usable field does.

Growing a VPC: secondary CIDR blocks

AWS does not let you increase or decrease an existing block, so a VPC that runs out of room gets a secondary block, an additional CIDR associated with the same VPC. A secondary block must be between /28 and /16 and must not overlap any block already on the VPC. Quotas limit both the blocks per VPC and the routes per route table. The primary block can never be disassociated. Each association adds a route to the new block with target local. The restrictions depend on the existing blocks, because some AWS services need non-conflicting CIDR blocks on the service side.

VPC already has a block inCannot addCan add
10.0.0.0/8Blocks from 172.16.0.0/12 or 192.168.0.0/16; 198.19.0.0/16; 10.0.0.0/16 if any block is in 10.0.0.0/15Other unrestricted 10.0.0.0/8 blocks; publicly routable non-RFC 1918 blocks; blocks from 100.64.0.0/10 (all /16 to /28)
172.16.0.0/12Blocks from other RFC 1918 ranges; 172.31.0.0/16; 198.19.0.0/16Other unrestricted 172.16.0.0/12 blocks; publicly routable blocks; blocks from 100.64.0.0/10

Two more rules catch people. A new block must not be the same as or larger than a destination range in a route in any of the VPC's route tables. In the guide's example, a VPC with primary block 10.2.0.0/16 has a route to 10.0.0.0/24 through a virtual private gateway (the AWS end of a VPN connection), so a secondary 10.0.0.0/24 or larger is refused and 10.0.0.0/25 or smaller works. Peering state matters too. While a peering connection is pending-acceptance, the requester's owner cannot add any block, and once it is active, new blocks must not overlap the peer. VPCs associated with a Direct Connect gateway must not overlap either, new blocks included. The lab tested none of these rules; they come from the VPC User Guide alone.

Symptom: associating a secondary block fails even though no subnet uses those addresses.

Cause: one of the guide's rules applies: a restricted range for the primary block's RFC 1918 family, a route whose destination is as large as the block, or a pending peering request.

Fix: choose a block from a permitted range and smaller than existing route destinations, or accept or delete the pending peering first. On the exam, running out of space is solved by adding a secondary block or a new VPC, never by resizing.

Overlap across the organization, and guardrails in the plan

Two ranges overlap when they share any address. For peering, the accepter VPC "cannot have a CIDR block that overlaps with the CIDR block of the requester VPC". With several IPv4 blocks, any overlap blocks the connection "even if you intend to use only the non-overlapping CIDR blocks or only IPv6 CIDR blocks". Peering is also not transitive, so a VPC cannot reach a peer's peer. A transit gateway "doesn't support routing between VPCs with identical CIDRs, or if a CIDR in a range overlaps a CIDR in an attached VPC". For a newly attached overlapping VPC, the routes "aren't propagated to the transit gateway route table". The guide describes missing routes, not a refused attachment, so this failure is quiet. Attaching also requires one subnet from each AZ. Amazon VPC IP Address Manager (IPAM) moves the check earlier. A scope is the top-level container, and each IPAM starts with a default private and a default public scope; and "Scopes enable you to reuse IP addresses across multiple unconnected networks without causing IP address overlap or conflict." A pool is a set of contiguous ranges and can nest inside another pool. An allocation is a CIDR assigned from a pool to a VPC. The lab did not create an IPAM. It encoded the overlap rule in the plan instead:

  # Two aligned CIDR blocks overlap exactly when the shorter one contains the
  # network address of the longer one.
  names = keys(local.all) # keys() returns names in lexical order
  pairs = flatten([for i, a in local.names : [for b in slice(local.names, i + 1, length(local.names)) : {
    a = a, b = b, ca = local.all[a], cb = local.all[b]
  }]])

  overlaps = [for p in local.pairs : "${p.a} ${p.ca} overlaps ${p.b} ${p.cb}"
    if(tonumber(split("/", p.ca)[1]) <= tonumber(split("/", p.cb)[1])
      ? cidrsubnet(format("%s/%s", split("/", p.cb)[0], split("/", p.ca)[1]), 0, 0) == p.ca
    : cidrsubnet(format("%s/%s", split("/", p.ca)[0], split("/", p.cb)[1]), 0, 0) == p.cb)
  ]

The pairs expression builds every unordered pair of names from the VPCs and the external ranges. For each pair, the overlaps condition finds the shorter prefix, rewrites the longer block's address with that prefix length, and compares the result to the shorter block. A match means the shorter block contains the longer block's network address, which is the comment's containment rule. Two other blocks complete the guardrails:

check "headroom" {
  assert {
    condition     = alltrue([for n in values(local.unused) : n >= 2])
    error_message = "Fewer than 2 unused subnet slots in at least one VPC: ${jsonencode(local.unused)}"
  }
}

output "overlaps" {
  value = local.overlaps
  precondition {
    condition     = length(local.overlaps) == 0
    error_message = "Overlapping ranges: ${join("; ", local.overlaps)}"
  }
}

The headroom check asserts that every VPC keeps at least two free slots and prints the counts as JSON. The overlaps output carries a precondition that the list is empty, and its message joins every conflicting pair, so one failed plan lists all conflicts at once. Step 7 fed in an acquired /17 inside prod and a partner range inside the on-premises range:

Error: Module output value precondition failed
  on main.tf line 101, in output "overlaps":
Overlapping ranges: acquired 10.20.128.0/17 overlaps prod 10.20.0.0/16;
on_prem 10.0.0.0/14 overlaps partner 10.2.0.0/16
[tofu plan exit 1]

The second overlap is between two external ranges, which is still a real conflict when both must route through one hub. The two blocks differ in strength. Validation blocks and output preconditions stop the plan; the documentation says preconditions "prevent OpenTofu from saving an invalid new output value in the state", OpenTofu's record of what it manages. Check blocks only warn, because "check blocks do not prevent or block the overall execution of OpenTofu operations". Brikman recommends validation blocks "for basic input sanitization" and preconditions "for checking basic assumptions", and notes that a validation condition "can only reference the surrounding input variable". His book covers Terraform, and OpenTofu uses the same block types. The overlap rule compares vpcs with external, so it has to live in an output precondition.

Symptom: a plan with overlapping VPCs exits 0 with only a warning.

Cause: the overlap rule was written as a check block assertion, which never blocks; the tight /24 plan above shows a failing check with exit 0.

Fix: keep overlap as a precondition (hard) and keep headroom advice as a check (soft).

Putting it together

For the opening scenario, reserve a non-overlapping supernet, one large block that holds all of an environment's VPC ranges, for each environment, which in IPAM means one pool per environment in the private scope. Size each VPC for tiers times AZs plus spare slots, as the nine of sixteen /20 slots did above. Compute usable addresses with the minus-five rule. Check size, canonical form, forbidden ranges and overlap before creation. The acquired VPC inside prod's range cannot join by peering, a transit gateway or a Direct Connect gateway, since each rejects or silently drops overlap. The fix within these sources is renumbering one side.

The lab verified OpenTofu 1.13.1 evaluation and ipcalc 1.0.3 arithmetic with no provider. It did not use an AWS account, make an API call, create an IPAM, associate a secondary CIDR block, or build a peering connection or transit gateway.

Exam traps:

Check yourself

Question 1
A subnet is created as 10.0.5.0/27. How many addresses can instances use?
tap to reveal
27 addresses. The /27 holds 32, and AWS reserves the first four and the last one, so 32 - 5 = 27.
Question 2
You request a VPC with 100.68.0.18/18. What block does AWS create, and why?
tap to reveal
100.68.0.0/18. Command-line and API calls rewrite a CIDR to canonical form with the host bits set to zero.
Question 3
A VPC has primary block 10.2.0.0/16 and a route to 10.0.0.0/24 through a virtual private gateway. Which secondary blocks can be associated: 10.0.0.0/24, 10.0.0.0/25, or both?
tap to reveal
Only 10.0.0.0/25. A new block must not be the same as or larger than a route destination in any of the VPC's route tables.
Question 4
A new VPC attached to a transit gateway overlaps an attached VPC. What does the guide say happens?
tap to reveal
Routes for the newly attached VPC are not propagated to the transit gateway route table, and the transit gateway does not support routing between overlapping CIDRs.
Question 5
Why does the lab put the overlap rule in an output precondition and the headroom advice in a check block?
tap to reveal
A precondition stops the plan, and overlap must stop it. A check block only warns, and the lab's tight plan exited 0 despite a failed check. A validation block cannot compare two variables.

Sources