Mission 2.1 · Stage 2 — Network
VPC and Subnets with for_each
Goal: A VPC with public, private, and database subnets in two Availability Zones, generated from one CIDR and an AZ list, never hand-typed.
By the end of this mission
- Read live data from AWS with a data source
- Calculate subnet ranges with
cidrsubnetand test them interraform console - Explain why
for_eachover a map beatscountfor things like subnets - Build maps and lists with
forexpressions for outputs
Part 1
Understand it first
Data sources: reading, not managing
A resource block means 'Terraform creates and owns this'. A data block means 'look this up and let me use it'. data "aws_availability_zones" "available" asks AWS which AZs this account can use in the current region. That list differs between regions and even between accounts, so hardcoding ap-south-1a would break the moment the code runs anywhere else.
Data sources are read during every plan and appear in state as mode: data. Their address is data.<type>.<name>, such as data.aws_availability_zones.available.names.
count vs for_each: identity by position vs by key
Both create many instances of one resource. count = 3 creates aws_subnet.private[0], [1], [2], identified by POSITION. If you remove the first AZ from a list, everything shifts down one place. Terraform sees [0] changing AZ (which forces replacement), [1] changing, and [2] disappearing, so it replaces subnets that had nothing to do with the change.
for_each over a map creates aws_subnet.private["ap-south-1a"] and aws_subnet.private["ap-south-1b"], identified by KEY. Removing one key affects only that instance. Rule of thumb: use for_each whenever instances have a natural identity (AZ, name, environment). Use count for 'N identical things' and for the on/off switch count = var.enabled ? 1 : 0.
cidrsubnet: network math as code
cidrsubnet(prefix, newbits, netnum) carves a smaller network out of a bigger one. From 10.20.0.0/16, adding 8 bits gives /24 subnets, and netnum picks which one: 0 gives 10.20.0.0/24, 10 gives 10.20.10.0/24. ShopLite reserves blocks by tier: public subnets from netnum 0, private from 10, database from 20. Each tier can grow to ten AZs without overlapping.
Deriving every CIDR from one vpc_cidr variable means a new environment only needs a new /16 (from the plan in the AWS course, Topic 3.1) and the rest follows automatically.
Part 2
Your project after this mission · 3 files change
- bootstrap/
- backend.tf
- main.tf
- infra/
- backend.tf
- locals.tf
- logs.tf
- network.tfnew
- outputs.tfmodified
- providers.tf
- refactors.tf
- storage.tf
- terraform.tfvars
- variables.tfmodified
- versions.tf
- .gitignore
Part 3
Build it, step by step
- 1
Try the math in terraform console first
terraform consoleevaluates expressions interactively, using your configuration's variables and state. Use it whenever you're unsure what a function orforexpression returns; it's much quicker than finding out from a plan.terminal$ cd infra && terraform console── expected output ──> cidrsubnet("10.20.0.0/16", 8, 0)"10.20.0.0/24"> cidrsubnet("10.20.0.0/16", 8, 10)"10.20.10.0/24"> { for i, az in ["ap-south-1a", "ap-south-1b"] : az => cidrsubnet("10.20.0.0/16", 8, i + 10) }{"ap-south-1a" = "10.20.10.0/24""ap-south-1b" = "10.20.11.0/24"}> exit - 2
Add network variables
can(cidrhost(...))is a validation trick:cidrhostfails on anything that isn't a valid CIDR, andcanturns that failure intofalse.infra/variables.tfadd to filehcl variable "vpc_cidr" { description = "CIDR block for the VPC. Must not overlap other environments." type = string default = "10.20.0.0/16" validation { condition = can(cidrhost(var.vpc_cidr, 0)) error_message = "vpc_cidr must be a valid IPv4 CIDR, e.g. 10.20.0.0/16." } } variable "az_count" { description = "How many Availability Zones to spread subnets across." type = number default = 2 validation { condition = var.az_count >= 2 && var.az_count <= 3 error_message = "Use 2 or 3 AZs." } } - 3
Write the VPC and the subnet maps
The locals build three maps keyed by AZ. Each subnet resource uses
for_eachover its map, witheach.keyas the AZ andeach.valueas the CIDR.merge()combines a common tag map with per-resource tags.Only public subnets get the
Tier = publictag; Stage 3's load balancer looks subnets up by tier, so tags here are functional, not decoration.infra/network.tfwhole filehcl data "aws_availability_zones" "available" { state = "available" } locals { azs = slice(data.aws_availability_zones.available.names, 0, var.az_count) public_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i) } private_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i + 10) } database_subnets = { for i, az in local.azs : az => cidrsubnet(var.vpc_cidr, 8, i + 20) } } resource "aws_vpc" "main" { cidr_block = var.vpc_cidr enable_dns_support = true enable_dns_hostnames = true tags = { Name = "${local.name_prefix}-vpc" } } resource "aws_subnet" "public" { for_each = local.public_subnets vpc_id = aws_vpc.main.id availability_zone = each.key cidr_block = each.value tags = { Name = "${local.name_prefix}-public-${each.key}", Tier = "public" } } resource "aws_subnet" "private" { for_each = local.private_subnets vpc_id = aws_vpc.main.id availability_zone = each.key cidr_block = each.value tags = { Name = "${local.name_prefix}-private-${each.key}", Tier = "private" } } resource "aws_subnet" "database" { for_each = local.database_subnets vpc_id = aws_vpc.main.id availability_zone = each.key cidr_block = each.value tags = { Name = "${local.name_prefix}-database-${each.key}", Tier = "database" } } - 4
Expose the IDs with for expressions
Subnet resources with
for_eachare MAPS of objects, soaws_subnet.private.iddoesn't exist. Aforexpression turns them into the list of IDs that other resources (the ALB, ECS, RDS) expect. Map iteration is sorted by key, so the order is stable.infra/outputs.tfadd to filehcl 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] } output "database_subnet_ids" { value = [for s in aws_subnet.database : s.id] } - 5
Plan — notice the instance keys
Each subnet appears with its key in brackets:
aws_subnet.private["ap-south-1a"]. That string is the instance's permanent identity in state.terminal$ terraform plan── expected output ──data.aws_availability_zones.available: Reading...data.aws_availability_zones.available: Read complete after 0s [id=ap-south-1]# aws_subnet.database["ap-south-1a"] will be created+ resource "aws_subnet" "database" {+ availability_zone = "ap-south-1a"+ cidr_block = "10.20.20.0/24"+ vpc_id = (known after apply)...# aws_subnet.private["ap-south-1a"] will be created+ resource "aws_subnet" "private" {+ availability_zone = "ap-south-1a"+ cidr_block = "10.20.10.0/24"...# aws_vpc.main will be created+ resource "aws_vpc" "main" {+ cidr_block = "10.20.0.0/16"+ enable_dns_hostnames = true...Plan: 7 to add, 0 to change, 0 to destroy.Changes to Outputs:+ database_subnet_ids = [+ (known after apply),+ (known after apply),]... - 6
Apply and check
The VPC is created first, then all six subnets in parallel, because they all depend on the VPC and not on each other.
terminal$ terraform applyterraform output private_subnet_ids── expected output ──aws_vpc.main: Creating...aws_vpc.main: Creation complete after 2s [id=vpc-0c1d2e3f4a5b6c7d8]aws_subnet.private["ap-south-1b"]: Creating...aws_subnet.public["ap-south-1a"]: Creating......Apply complete! Resources: 7 added, 0 changed, 0 destroyed.["subnet-0aa11bb22cc33dd44","subnet-0ee55ff66aa77bb88",]
Checkpoint — you should now have
- ✓A VPC
10.20.0.0/16exists with six subnets across two AZs, each tagged with its tier. - ✓
terraform state listshows subnets addressed by AZ key, likeaws_subnet.private["ap-south-1a"]. - ✓No CIDR or AZ name is hardcoded in
network.tf. - ✓You can evaluate
cidrsubnetinterraform console.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
See why count is fragile
In a scratch copy of the config, write the private subnets with count = length(local.azs) using local.azs[count.index], with three AZs ["ap-south-1a", "ap-south-1b", "ap-south-1c"]. Apply, then remove ap-south-1a from the list and plan.
resource "aws_subnet" "private" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
availability_zone = local.azs[count.index]
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
}Break #2
Make two tiers overlap
Change the database map to use cidrsubnet(var.vpc_cidr, 8, i + 10), the same offset as private, and apply.
Part 5
Interview questions from this mission
When do you use count and when for_each?
What's the difference between a resource and a data source?
How would you generate subnet CIDRs for a VPC without hardcoding them?