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.
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
movedblocks 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).
Part 2
Your project after this mission · 14 files change
- 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
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.tfdeclares 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
Move the resources in
Cut everything from
infra/network.tfintomodules/network/main.tf, then make three kinds of edit:local.name_prefixbecomesvar.name_prefix; the VPC's local name becomesthis(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. Deleteinfra/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
Call the module from the root
A new
infra/main.tfholds module calls. The arguments are the module's variables. Then runterraform 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
Rewrite every reference to go through outputs
terraform validatefinds every broken reference for you. Work through the list:aws_vpc.main.idbecomesmodule.network.vpc_id,[for s in aws_subnet.public : s.id]becomesmodule.network.public_subnet_ids, and so on. The probe usesmodule.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
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
Add moved blocks across the module boundary
movedworks across modules:fromis the old root address,tois the address inside the module. Forfor_eachresources 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
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.thisresource "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/networkcontains the VPC, subnets, routing, NAT, and S3 endpoint, and no provider block. - ✓
infra/network.tfis gone; the root reads network values only throughmodule.network.*outputs. - ✓The refactor applied with
0 to add, 0 to change, 0 to destroy. - ✓
curl $URL/healthz/dbstill 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.
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.
Part 5
Interview questions from this mission
How do you refactor existing Terraform resources into a module without recreating them?
Why shouldn't a reusable module contain a provider block?
What should and shouldn't be a module?