Hub-and-spoke networking
Hub-and-spoke networking connects every VPC in the organization through one Transit Gateway in the network account. Workload VPCs (the spokes) have no internet egress of their own; their private traffic leaves through the Transit Gateway to the hub VPC, which owns centralized NAT and, optionally, egress inspection.
Why it matters in production
Peering every VPC to every other VPC does not scale and gives no single place to control or inspect traffic. A hub gives one egress path to secure, one place to add inspection later, and one route table per isolation domain. It also removes a NAT Gateway per account. Centralized addressing matters for the same reason: overlapping CIDRs break Transit Gateway and peering connectivity, and re-addressing a live network means rebuilding it.
How the platform applies it
The hub
The hub VPC (acme-usw2-network-vpc, from units/vpc-hub) spans three Availability Zones with an Internet Gateway and one NAT Gateway per AZ, so an AZ failure in the hub no longer stops every spoke's egress and cross-AZ NAT charges disappear. Per-AZ firewall subnets are reserved from the VPC map for the inspection option. The Transit Gateway (acme-usw2-tgw, from units/transit-gateway) is shared with the whole organization through AWS Resource Access Manager and auto-accepts attachments from member accounts.
The spokes
Each workload account's stack file instantiates stacks/plat-account, which gives it a spoke VPC (units/vpc-spoke), an S3 gateway endpoint, a Transit Gateway attachment, an association with the private DNS zone and an ssm-publish unit. A spoke has private subnets for workloads and public subnets only for internet-facing load balancers. It keeps an Internet Gateway for those load balancers and nothing else: private route tables send 0.0.0.0/0 to the Transit Gateway. The runner VPC in core-auto has private subnets only.
Isolation domains
The Transit Gateway is created with default route-table association and propagation disabled, so an attachment reaches nothing until the transit-gateway-routes unit places it in a table. Domains are declared in one file:
isolation_domains = {
prod = ["prod"]
nonprod = ["sandbox", "dev", "staging"]
}
Each domain gets its own route table; shared holds the hub and runner VPCs and learns every attachment. Every spoke reaches the hub (NAT egress, Client VPN, the DNS resolver) and the CI runners. A stage belongs to exactly one domain; shared is reserved; the module rejects anything else at plan time.
Production and non-production cannot reach each other, and it takes two rules to make that true. Two domain tables never learn each other's routes, so neither has a path to the other. And each domain table carries a blackhole route for the address range of every stage outside it, drawn from the same IPAM output as the hub return routes. A blackhole beats 0.0.0.0/0 on longest-prefix match, so a packet aimed across the boundary is dropped at the Transit Gateway instead of following the default route to the hub and being routed back down the other domain's path. The shared table carries no blackhole: the hub, the runners and Client VPN users reach every stage on purpose.
One address plan
Every CIDR in the landing zone comes from one file at the repository root, eks_vpc_map.yaml (a compact non_eks_vpc_map.yaml is the alternative). The ipam unit reads it, builds the IPAM pool hierarchy (organization, region, OU, account VPC) and publishes every allocation as outputs. Every VPC unit looks up its addresses with one accessor:
cidr = dependency.ipam.outputs.vpc_cidrs_by_region[include.root.locals.aws_region][include.root.locals.ipam_key].primary_cidr
private_subnets = dependency.ipam.outputs.vpc_cidrs_by_region[include.root.locals.aws_region][include.root.locals.ipam_key].private
public_subnets = dependency.ipam.outputs.vpc_cidrs_by_region[include.root.locals.aws_region][include.root.locals.ipam_key].public
ipam_key is <ou>-<account_name>, such as plat-prod or core-network. No other file contains an IP range; scripts/check-no-hardcoded-cidrs.py fails the build on any private CIDR literal, and scripts/check-vpc-maps.py checks that both maps are valid, non-overlapping and that every unit's key resolves. Choosing a map is a one-time decision before the first deployment: every address differs between them, so switching later is a network rebuild.
Switches with prices
The region's network.hcl holds the choices several units must agree on, each with its cost in a comment: nat_gateway_per_az (on; about $32 per month per additional gateway), enable_network_firewall (off; about $865 per month for three AZ endpoints plus $0.065 per GB) and enable_tgw_peering (off; about $36 per month per peering, only meaningful with a second region). Every VPC sends flow logs to the central audit bucket.
Worked example: a request from dev to the internet
A pod in the dev cluster opens an HTTPS connection. The dev VPC's private route table sends it to the Transit Gateway attachment. The nonprod route table carries 0.0.0.0/0 toward the hub attachment. In the hub, the attachment subnet's route table sends it to the NAT Gateway in the same AZ (or, when inspection is on, to the firewall endpoint first), and the NAT Gateway sends it out through the Internet Gateway. The return path is symmetric: the hub's route tables send the plat address range back to the Transit Gateway, and the shared table the hub attachment uses has learned every spoke. Centralized egress is one of the layers described under defense in depth, on top of the account boundaries of the landing zone.
Common mistakes
- Typing a CIDR anywhere but the VPC map. The guard rejects it, and a copied address is exactly how overlaps happen.
- Adding NAT to a spoke. Spokes egress through the hub by design; a local NAT bypasses the hub egress path and the inspection option.
- Switching the VPC map after the first apply. Every CIDR differs between the maps, so every VPC, subnet, pool and route is destroyed and recreated.
- Expecting a new spoke to reach anything before its association exists. An attachment reaches nothing until
transit-gateway-routesplaces it in a domain table. - Putting a stage in two domains, or none. The module rejects the plan; each plat account belongs to exactly one domain.