Command Palette

Search for a command to run...

Hectal

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.

40 min Free — VPCs and subnets have no hourly charge 6 steps 2 break-it drills

By the end of this mission

  • Read live data from AWS with a data source
  • Calculate subnet ranges with cidrsubnet and test them in terraform console
  • Explain why for_each over a map beats count for things like subnets
  • Build maps and lists with for expressions 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.

What this mission createsdiagram
Rendering diagram…

Part 2

Your project after this mission · 3 files change

shoplite/
  • 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. 1

    Try the math in terraform console first

    terraform console evaluates expressions interactively, using your configuration's variables and state. Use it whenever you're unsure what a function or for expression 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. 2

    Add network variables

    can(cidrhost(...)) is a validation trick: cidrhost fails on anything that isn't a valid CIDR, and can turns that failure into false.

    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. 3

    Write the VPC and the subnet maps

    The locals build three maps keyed by AZ. Each subnet resource uses for_each over its map, with each.key as the AZ and each.value as the CIDR. merge() combines a common tag map with per-resource tags.

    Only public subnets get the Tier = public tag; 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. 4

    Expose the IDs with for expressions

    Subnet resources with for_each are MAPS of objects, so aws_subnet.private.id doesn't exist. A for expression 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. 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. 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 apply
    terraform 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/16 exists with six subnets across two AZs, each tagged with its tier.
  • ✓terraform state list shows subnets addressed by AZ key, like aws_subnet.private["ap-south-1a"].
  • ✓No CIDR or AZ name is hardcoded in network.tf.
  • ✓You can evaluate cidrsubnet in terraform 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.

scratch/count-demo.tfwhole filehcl
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)
}
terminal
$ terraform plan
── what you'll see ──
# aws_subnet.private[0] must be replaced
-/+ resource "aws_subnet" "private" {
~ availability_zone = "ap-south-1a" -> "ap-south-1b" # forces replacement
~ cidr_block = "10.20.10.0/24" -> "10.20.10.0/24"
...
# aws_subnet.private[1] must be replaced
-/+ resource "aws_subnet" "private" {
~ availability_zone = "ap-south-1b" -> "ap-south-1c" # forces replacement
...
# aws_subnet.private[2] will be destroyed
- resource "aws_subnet" "private" {
 
Plan: 2 to add, 0 to change, 3 to destroy.

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.

terminal
$ terraform apply
── what you'll see ──
aws_subnet.database["ap-south-1a"]: Creating...
╷
│ Error: creating EC2 Subnet: operation error EC2: CreateSubnet, https
│ response error StatusCode: 400, RequestID: 91c..., api error
│ InvalidSubnet.Conflict: The CIDR '10.20.10.0/24' conflicts with another
│ subnet
│
│ with aws_subnet.database["ap-south-1a"],
│ on network.tf line 42, in resource "aws_subnet" "database":
╵

Part 5

Interview questions from this mission

01

When do you use count and when for_each?

02

What's the difference between a resource and a data source?

03

How would you generate subnet CIDRs for a VPC without hardcoding them?

0/4 · 0%