Command Palette

Search for a command to run...

Hectal

Mission 5.1 · Stage 5 — Modules

Extract the Network into a Module — Without Touching a Resource

Goal: All VPC, subnet, routing, NAT, and endpoint resources live in modules/network, the root calls it once, and the refactor applies with 0 to add, 0 to change, 0 to destroy.

50 min Free 7 steps 2 break-it drills

By the end of this mission

  • Explain root modules, child modules, inputs, and outputs
  • Move resources into a module and rewrite references to module outputs
  • Use moved blocks across module boundaries
  • Know why modules must not contain provider blocks

Part 1

Understand it first

Every folder is a module

The folder you run terraform in is the ROOT MODULE. A module "name" { source = "..." } block calls a CHILD MODULE, another folder of .tf files. Its variable blocks become the arguments you pass, and its output blocks are the only things the caller can read. Resources inside get addresses like module.network.aws_vpc.this.

This is encapsulation: the root can't reach inside and read module.network.aws_subnet.public directly, only module.network.public_subnet_ids. So the module's author can reorganise its internals freely as long as the outputs stay the same, just like a function's body behind its signature.

What makes a good module boundary

Group resources that change together and are meaningless apart. The network (VPC, subnets, routes, NAT, endpoints) is a classic unit, and so is 'one ECS service with its task definition and autoscaling'. Don't wrap single resources in modules ('an S3 bucket module' that just passes every argument through); that adds indirection without hiding any complexity.

Security groups stay in the root for now. They connect the network, the ALB, the app, and the database, so they're wiring between modules, which is the root module's job.

Providers come from the caller

Child modules declare which providers they need (required_providers in a versions.tf) but never configure them: no provider "aws" { region = ... } inside a module. The root configures providers once and modules inherit them. That keeps modules reusable in any region or account, and it's required for using count, for_each, or depends_on on a module call (see Break it in 5.2).

Before and afterdiagram
Rendering diagram…

Part 2

Your project after this mission · 14 files change

shoplite/
  • infra/
    • alb.tfmodified
    • autoscaling.tf
    • backend.tf
    • checks.tf
    • database.tfmodified
    • ecr.tf
    • ecs.tfmodified
    • iam.tf
    • locals.tf
    • logs.tf
    • main.tfnew
    • network.tfdeleted
    • outputs.tfmodified
    • probe.tfmodified
    • providers.tf
    • refactors.tfmodified
    • security.tfmodified
    • storage.tf
    • terraform.tfvars
    • uploads.tf
    • variables.tfmodified
    • versions.tf
  • modules/
    • network/
      • main.tfnew
      • outputs.tfnew
      • variables.tfnew
      • versions.tfnew

Part 3

Build it, step by step

  1. 1

    Create the module's interface first

    Start with inputs and outputs, the module's contract. Everything the network code read from the root (local.name_prefix, var.vpc_cidr, the NAT switches) becomes a variable. Everything other code read from it (VPC ID, subnet IDs, AZs) becomes an output. versions.tf declares the provider requirement, but no provider configuration.

    modules/network/variables.tf + outputs.tf + versions.tfwhole filehcl
    # ── variables.tf ──────────────────────────────
    variable "name_prefix" {
      description = "Prefix for resource names, e.g. shoplite-dev."
      type        = string
    }
    
    variable "vpc_cidr" {
      type = string
      validation {
        condition     = can(cidrhost(var.vpc_cidr, 0))
        error_message = "vpc_cidr must be a valid IPv4 CIDR."
      }
    }
    
    variable "az_count" {
      type    = number
      default = 2
    }
    
    variable "enable_nat_gateway" {
      type    = bool
      default = true
    }
    
    variable "single_nat_gateway" {
      type    = bool
      default = true
    }
    
    # ── outputs.tf ────────────────────────────────
    output "vpc_id" {
      value = aws_vpc.this.id
    }
    
    output "azs" {
      value = local.azs
    }
    
    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]
    }
    
    # ── versions.tf ───────────────────────────────
    terraform {
      required_version = ">= 1.10"
      required_providers {
        aws = { source = "hashicorp/aws", version = ">= 6.0" }
      }
    }
  2. 2

    Move the resources in

    Cut everything from infra/network.tf into modules/network/main.tf, then make three kinds of edit: local.name_prefix becomes var.name_prefix; the VPC's local name becomes this (the convention for 'the main resource of this module'); and the S3 endpoint's region comes from a data source, since the module shouldn't need a region variable. Delete infra/network.tf.

    modules/network/main.tfwhole filehcl

    Abbreviated: the subnet, route, NAT, and association blocks are exactly as in Mission 2.1/2.2 with the renames applied.

    data "aws_availability_zones" "available" {
      state = "available"
    }
    
    data "aws_region" "current" {}
    
    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) }
      nat_azs = !var.enable_nat_gateway ? [] : (
        var.single_nat_gateway ? [local.azs[0]] : local.azs
      )
    }
    
    resource "aws_vpc" "this" {
      cidr_block           = var.vpc_cidr
      enable_dns_support   = true
      enable_dns_hostnames = true
      tags                 = { Name = "${var.name_prefix}-vpc" }
    }
    
    resource "aws_subnet" "public" {
      for_each          = local.public_subnets
      vpc_id            = aws_vpc.this.id
      availability_zone = each.key
      cidr_block        = each.value
      tags              = { Name = "${var.name_prefix}-public-${each.key}", Tier = "public" }
    }
    
    # aws_subnet.private, aws_subnet.database, aws_internet_gateway.main,
    # aws_route_table.public, aws_route.public_internet, aws_route_table_association.public,
    # aws_eip.nat, aws_nat_gateway.main, aws_route_table.private, aws_route.private_nat,
    # aws_route_table_association.private — unchanged apart from var.name_prefix / aws_vpc.this
    
    resource "aws_vpc_endpoint" "s3" {
      vpc_id            = aws_vpc.this.id
      service_name      = "com.amazonaws.${data.aws_region.current.region}.s3"
      vpc_endpoint_type = "Gateway"
      route_table_ids   = [for rt in aws_route_table.private : rt.id]
      tags              = { Name = "${var.name_prefix}-s3-endpoint" }
    }
  3. 3

    Call the module from the root

    A new infra/main.tf holds module calls. The arguments are the module's variables. Then run terraform init, since new modules must be installed (for a local path, init just records it).

    infra/main.tfwhole filehcl
    module "network" {
      source = "../modules/network"
    
      name_prefix        = local.name_prefix
      vpc_cidr           = var.vpc_cidr
      az_count           = var.az_count
      enable_nat_gateway = var.enable_nat_gateway
      single_nat_gateway = var.single_nat_gateway
    }
    terminal
    $ terraform init
    ── expected output ──
    Initializing the backend...
    Initializing modules...
    - network in ../modules/network
    ...
    Terraform has been successfully initialized!
  4. 4

    Rewrite every reference to go through outputs

    terraform validate finds every broken reference for you. Work through the list: aws_vpc.main.id becomes module.network.vpc_id, [for s in aws_subnet.public : s.id] becomes module.network.public_subnet_ids, and so on. The probe uses module.network.private_subnet_ids[0], and root outputs forward the module's outputs.

    terminal
    $ terraform validate
    ── expected output ──
    ╷
    │ Error: Reference to undeclared resource
    │
    │ on alb.tf line 5, in resource "aws_lb" "main":
    │ 5: subnets = [for s in aws_subnet.public : s.id]
    │
    │ A managed resource "aws_subnet" "public" has not been declared in the root
    │ module.
    ╵
    ... (one error per old reference — fix them all, then:)
    Success! The configuration is valid.
  5. 5

    Plan WITHOUT moved blocks — and read it carefully

    Terraform sees every network resource at a new address. The plan wants to destroy the VPC and everything in it (with RDS and ECS inside!) and build a new one. Apply this and ShopLite goes down; in fact the apply would fail halfway, because the old subnets are still in use. Stop here.

    terminal
    $ terraform plan | tail -n 1
    ── expected output ──
    Plan: 21 to add, 0 to change, 21 to destroy.
  6. 6

    Add moved blocks across the module boundary

    moved works across modules: from is the old root address, to is the address inside the module. For for_each resources you move the whole resource, and all its instances move with their keys. Data sources don't need moving, since they're re-read every plan.

    infra/refactors.tfadd to filehcl
    # 2026-09: network extracted into modules/network
    moved {
      from = aws_vpc.main
      to   = module.network.aws_vpc.this
    }
    moved {
      from = aws_subnet.public
      to   = module.network.aws_subnet.public
    }
    moved {
      from = aws_subnet.private
      to   = module.network.aws_subnet.private
    }
    moved {
      from = aws_subnet.database
      to   = module.network.aws_subnet.database
    }
    moved {
      from = aws_internet_gateway.main
      to   = module.network.aws_internet_gateway.main
    }
    moved {
      from = aws_route_table.public
      to   = module.network.aws_route_table.public
    }
    moved {
      from = aws_route.public_internet
      to   = module.network.aws_route.public_internet
    }
    moved {
      from = aws_route_table_association.public
      to   = module.network.aws_route_table_association.public
    }
    moved {
      from = aws_eip.nat
      to   = module.network.aws_eip.nat
    }
    moved {
      from = aws_nat_gateway.main
      to   = module.network.aws_nat_gateway.main
    }
    moved {
      from = aws_route_table.private
      to   = module.network.aws_route_table.private
    }
    moved {
      from = aws_route.private_nat
      to   = module.network.aws_route.private_nat
    }
    moved {
      from = aws_route_table_association.private
      to   = module.network.aws_route_table_association.private
    }
    moved {
      from = aws_vpc_endpoint.s3
      to   = module.network.aws_vpc_endpoint.s3
    }
  7. 7

    Plan again: pure moves

    Every resource 'has moved to' its module address and the summary is all zeros. If ANY create or destroy remains, a moved block is missing or a resource changed while moving (for example a changed tag), so fix it before applying.

    terminal
    $ terraform apply
    ── expected output ──
    # aws_vpc.main has moved to module.network.aws_vpc.this
    resource "aws_vpc" "this" {
    id = "vpc-0c1d2e3f4a5b6c7d8"
    # (17 unchanged attributes hidden)
    }
    # aws_subnet.private["ap-south-1a"] has moved to module.network.aws_subnet.private["ap-south-1a"]
    ...
    Plan: 0 to add, 0 to change, 0 to destroy.
    ...
    Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Checkpoint — you should now have

  • ✓modules/network contains the VPC, subnets, routing, NAT, and S3 endpoint, and no provider block.
  • ✓infra/network.tf is gone; the root reads network values only through module.network.* outputs.
  • ✓The refactor applied with 0 to add, 0 to change, 0 to destroy.
  • ✓curl $URL/healthz/db still returns ok, since nothing real changed.

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

Reach inside the module

In alb.tf, write subnets = [for s in module.network.aws_subnet.public : s.id] and run terraform validate.

terminal
$ terraform validate
── what you'll see ──
╷
│ Error: Unsupported attribute
│
│ on alb.tf line 5, in resource "aws_lb" "main":
│ 5: subnets = [for s in module.network.aws_subnet.public : s.id]
│ ├────────────────
│ │ module.network is a object
│
│ This object does not have an attribute named "aws_subnet".
╵

Break #2

Change the module without re-running init

Rename the module call from module "network" to module "net" (and fix references), then run terraform plan without terraform init.

terminal
$ terraform plan
── what you'll see ──
╷
│ Error: Module not installed
│
│ on main.tf line 1:
│ 1: module "net" {
│
│ This module is not yet installed. Run "terraform init" to install all
│ modules required by this configuration.
╵

Part 5

Interview questions from this mission

01

How do you refactor existing Terraform resources into a module without recreating them?

02

Why shouldn't a reusable module contain a provider block?

03

What should and shouldn't be a module?

0/4 · 0%