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.
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.
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.
Six scenarios. The thread: addresses are identity.
./modules/storage/
(with variables.tf / outputs.tf), calling it from root.terraform plan — watch the destroy/create pair. Don't apply.moved block, plan again — watch it become a zero-change move
("has moved to").terraform state list — see the module.storage.… namespace.