A single main.tf that started as a weekend prototype becomes an eight-hundred-line wall nobody wants to touch. The fix is not more comments. It is a set of modules with real boundaries — a clear input contract, sensible defaults, and versioned sources you can reason about a piece at a time.
Most Terraform starts flat, and that is correct. Premature abstraction is the more common failure, not the rarer one. But there is a point where a flat configuration stops being readable and starts being a liability: every plan touches everything, a typo in one resource blocks an unrelated change, and no two engineers can work in the file at once without a merge conflict. This is a refactor into modules that are genuinely reusable — written against Terraform 1.9 — and, just as important, a rule for when not to bother.
When a module earns its keep
A module is worth creating when it satisfies at least one of three tests, and worth resisting when it satisfies none of them.
Free · 4 minutes
If your most senior engineer left tomorrow, would anyone still understand the system?
Fourteen questions on documentation, dependencies, and the gap between how the architecture works and how many people know it. Banded finding on screen, full sheet by email.
- It is used more than once. The same cluster of resources — a network, a database with its subnet group and parameter group, a service with its role and log group — appears in two or more places. Duplication is the clearest signal.
- It has a boundary you can name. If you can describe what goes in and what comes out in one sentence — “give it a CIDR and an environment, get back a VPC id and subnet ids” — it is a module. If you cannot, it is not yet one.
- It changes on its own cadence. Infrastructure that is reviewed, versioned or owned separately from the rest deserves its own boundary so it can move without dragging everything else with it.
A module wrapping a single resource with no added logic is almost always premature. If the module body is one resource block and a pass-through variable for every argument, you have added a layer of indirection and gained nothing. Delete it and inline the resource. The point of a module is to hide a decision, not to rename an argument.
The input contract is the module
A reusable module is defined by its variables and outputs, not its resources. Treat the variable file as an API: every input carries a description, a type, and validation where a bad value would otherwise fail deep inside a provider with an unhelpful message. Terraform 1.9 made this materially better — a validation block can now reference other variables, not just the one it belongs to, so you can express rules that span inputs.
# modules/network/variables.tf
variable "environment" {
description = "Deployment environment. Drives naming, tagging and defaults."
type = string
validation {
condition = contains(["development", "staging", "production"], var.environment)
error_message = "environment must be one of development, staging or production."
}
}
variable "vpc_cidr" {
description = "IPv4 CIDR for the VPC. Use a /16 to /20 to leave room for subnets."
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "vpc_cidr must be a valid IPv4 CIDR block, e.g. 10.20.0.0/16."
}
}
# Terraform 1.9: a validation may reference another variable.
variable "log_retention_days" {
description = "CloudWatch log retention. Production must keep at least 30 days."
type = number
default = 14
validation {
condition = var.environment != "production" || var.log_retention_days >= 30
error_message = "log_retention_days must be at least 30 in the production environment."
}
}
# An optional() object gives callers a small settings surface with defaults,
# so the common case stays a one-line module call.
variable "settings" {
description = "Optional network toggles; sensible defaults for most callers."
type = object({
enable_flow_logs = optional(bool, true)
az_count = optional(number, 2)
})
default = {}
}
The discipline here pays off twice: the caller gets an error at plan time in plain English, and the variable descriptions become the documentation. Anyone can run terraform-docs against the module and get an accurate interface reference for free, because the interface is written down where it is enforced.
Outputs and a root that only composes
Outputs are the other half of the contract. Export exactly what a consumer needs to attach to the module — ids, ARNs, endpoints — and nothing internal. If an output is not consumed by anything, it is noise; remove it. Keep the module’s versions.tf with its required providers alongside it, so the module declares its own constraints rather than inheriting them silently.
# modules/network/outputs.tf
output "vpc_id" {
description = "VPC id, for consumers attaching subnets, endpoints or peering."
value = aws_vpc.this.id
}
output "private_subnet_ids" {
description = "Private subnet ids in creation order, one per availability zone."
value = aws_subnet.private[*].id
}
The root module — one per environment — should read like a table of contents. It composes modules and wires their outputs together. It should contain almost no resources of its own.
# envs/production/main.tf
module "network" {
source = "app.terraform.io/acme/network/aws"
version = "~> 2.4"
environment = "production"
vpc_cidr = "10.20.0.0/16"
log_retention_days = 30
}
module "api" {
source = "app.terraform.io/acme/service/aws"
version = "~> 1.7"
environment = "production"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
}
Versioned sources, and the trap in the version argument
Reuse without versioning is not reuse; it is a shared mutable dependency that breaks every consumer the moment you change it. Pin every module source. The mechanism, though, depends on where the module lives, and this catches people out. The version argument works only for modules pulled from a registry — the public Terraform Registry, a private registry, or Terraform Cloud. For a Git source there is no version argument; you pin with a ref in the URL instead.
# Registry module: use the version argument with a constraint.
module "network" {
source = "app.terraform.io/acme/network/aws"
version = "~> 2.4" # >= 2.4.0 and < 2.5.0
}
# Git module: pin with ?ref= to a tag or commit. No version argument exists.
module "network" {
source = "git::https://github.com/acme/tf-modules.git//network?ref=v2.4.1"
}
Prefer the pessimistic constraint ~> for shared modules: it accepts patch and minor updates but holds the major version, which is where breaking changes belong under semantic versioning. Pin to an exact tag for anything you cannot afford to have move under you. Tracking module versions across environments is also one of the cleaner ways to keep infrastructure change velocity legible — the kind of thing that shows up in the DORA four metrics when you can promote a module bump through environments deliberately rather than editing production by hand.
Test a module in isolation
The payoff of a real boundary is that you can exercise the module on its own. Terraform’s native test framework (terraform test, stable since 1.6) runs .tftest.hcl files that stand the module up with sample inputs and assert on the plan or the outputs — no wrapper project required.
# modules/network/tests/defaults.tftest.hcl
run "rejects_short_retention_in_production" {
command = plan
variables {
environment = "production"
vpc_cidr = "10.20.0.0/16"
log_retention_days = 7
}
expect_failures = [var.log_retention_days]
}
run "applies_sane_defaults" {
command = plan
variables {
environment = "staging"
vpc_cidr = "10.10.0.0/16"
}
assert {
condition = length(aws_subnet.private) == 2
error_message = "Expected two private subnets from the default az_count."
}
}
Wire terraform fmt -check, terraform validate and terraform test into the same enforcement layer as the rest of your repository so a broken module contract cannot merge — the same pre-commit and CI baseline you use for application code applies cleanly to infrastructure.
The goal was never modules for their own sake. It is that when something breaks at two in the morning, you can open one module, understand its inputs and outputs without reading the whole estate, and change it with confidence that its version pin will not surprise the three other environments that depend on it. That is what reusable actually means.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Can you trust the architecture you have?
Architecture diagrams rarely show the reality of how systems actually operate. An independent review establishes what is really there.