Lesson 0004 · Understand Terraform

Modules: the unit of structure

One idea · ~12 min. A module is a function — and its name is a state-address namespace.
Why this next? "How do I structure real projects" — your named black box #2 — is a two-lesson arc. Structure is built from modules, so the building block comes first. The payoff scenario at the end connects modules back to everything you know about state.

Strip away the mystique: a module is a directory of .tf files. That's the whole definition. You've been inside one all along — the directory you run terraform in is the root module.

Think: function

Variables are the parameters. Outputs are the return values. The resources inside are implementation details — private unless an output exposes them.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"   # where the code lives
  version = "~> 5.8"                           # always pin registry sources
  cidr    = var.vpc_cidr                        # argument in
}

resource "aws_instance" "app" {
  subnet_id = module.vpc.private_subnet_ids[0] # return value out
}

And the part most people miss: the call's name becomes a namespace in state. That bucket you wrote in lesson 0001 lives at aws_s3_bucket.logs; put it inside module "storage" and its address is module.storage.aws_s3_bucket.logs. Hold that thought — it's the trap in the quiz.

The standard structure (which registries and doc tools understand): main.tf + variables.tf + outputs.tf + README.md, nested helpers under modules/, runnable examples/. Descriptions on every variable and output — they're your API docs. Design guidance from HashiCorp: many small, composable modules beat one deep hierarchy, and a module should raise the abstraction level — if you can't name the concept it represents ("hardened VPC", "service + alarms"), it isn't a module yet, it's indirection.

The provider rule: reusable modules declare required_providers (what they need) but never contain provider blocks (how to connect). Provider configuration belongs to the root module. A legacy provider block inside a module blocks count/for_each on the call and orphans resources when the module is removed.

You're refactoring — you call it

Six scenarios. The thread: addresses are identity.

✅ Prove it on your own machine (optional, 10 min)

  1. Take any working config and pull one resource into ./modules/storage/ (with variables.tf / outputs.tf), calling it from root.
  2. Run terraform plan — watch the destroy/create pair. Don't apply.
  3. Add the moved block, plan again — watch it become a zero-change move ("has moved to").
  4. Apply, then terraform state list — see the module.storage.… namespace.
💬 Good follow-ups: "When do I split one module into two?" · "How do versioned module releases work across a team?" · "What's the difference between moved and terraform state mv under the hood?"