Package configuration into reusable modules. Build a local child module with inputs and outputs, call it many times, and pull battle-tested modules from the Registry.
Why: every Terraform project is already a module — the root module, the directory you run commands in. A child module is a reusable directory the root calls to create a set of resources together. Modules are how you stop copy-pasting the same blocks and package infrastructure as a named, versioned unit.
project/ ← root module (you run terraform here)
├── main.tf calls the child module
└── modules/
└── website/ ← child module (reusable)
├── variables.tf inputs
├── main.tf resources
└── outputs.tf outputsWhy: a child module is just .tf files in a directory — variables are its inputs, resources its body, outputs its return values. It looks like any configuration; what makes it a module is that something else calls it. Here is a tiny one that writes a configurable file.
# modules/note/variables.tf
variable "name" { type = string }
variable "message" { type = string }
# modules/note/main.tf
resource "local_file" "note" {
filename = "${var.name}.txt"
content = var.message
}
# modules/note/outputs.tf
output "path" {
value = local_file.note.filename
}Why: a module block calls a child module, passing values to its input variables. source points at the directory (or a Registry address or git URL). Call the same module many times with different inputs, and read its outputs as module.<name>.<output>.
module "welcome" {
source = "./modules/note"
name = "welcome"
message = "Hello!"
}
module "todo" {
source = "./modules/note"
name = "todo"
message = "Learn Terraform modules"
}
output "welcome_path" {
value = module.welcome.path # read a module output
}Why: you rarely need to write infrastructure from scratch — the Registry has thousands of maintained modules (a full AWS VPC, a GKE cluster). Reference one by its registry address and pin a version, exactly like a provider. Run init to download it.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["eu-west-1a", "eu-west-1b"]
}Note: a good module is small and focused — it does one thing (a network, a service) rather than everything. Expose only the inputs callers actually need, give every variable a type and description, return the values others will reference as outputs, and pin module versions just like providers. Treat a module like an API: stable inputs and outputs, hidden internals.
✓ one clear responsibility per module
✓ typed variables with descriptions (the module's inputs)
✓ outputs for anything callers need (the module's return values)
✓ pin versions for Registry/git modules
✗ don't build one giant "does-everything" module