TL;DR: A Terraform module is infrastructure with an interface: variables in, outputs out, opinions inside. Good modules wrap one coherent thing (a network, a cluster, an application stack), expose only the decisions callers genuinely need to make, give sensible defaults for the rest, and publish outputs that let other modules compose on top. Version them like software — semantic versions, pinned references, a changelog — because a module edit silently changes every environment that consumes it on the next plan. The failure modes are consistent: the god-module that tries to cover every case and satisfies none, the abstraction leak that forces callers to know the internals anyway, and the unpinned
mainbranch reference that turns a routine apply into a surprise upgrade. Modules done well are how one engineer maintains ten environments; done badly, they're how a copy-paste problem becomes a shared copy-paste problem.
Originally published on DevOps Enginer.
Every growing Terraform codebase reaches the same moment: the staging VPC definition looks exactly like the production one except for six values, someone copies the folder, and from that day the two networks drift apart one forgotten edit at a time. Modules are Terraform's answer — and like most answers, they have their own failure modes. This guide covers the design decisions that separate a module library from a junk drawer.
What a module actually is
Strip the ceremony away: any directory of .tf files is a module. The root module is the one you run terraform apply in; child modules are directories (or registry entries, or git repos) that the root calls with a module block:
module "network" {
source = "git::https://example.com/infra-modules.git//network?ref=v1.4.0"
# or: source = "./modules/network" (local)
# or: source = "terraform-aws-modules/vpc/aws" (registry)
cidr_block = "10.20.0.0/16"
az_count = 3
enable_flow_logs = true
}
output "private_subnet_ids" {
value = module.network.private_subnet_ids
}
The contract is the whole idea: variables are the questions the module asks, outputs are the promises it makes, and everything in between is its private business. A caller should never need to read a module's internals to use it — the moment they do, the abstraction has leaked and the module has failed at its one job.
Designing the interface (where modules are won or lost)
Expose decisions, not resources. Callers should pass intent — "3 AZs, flow logs on, this CIDR" — not raw resource arguments copied from the AWS provider docs. If your variable list mirrors the underlying resource's argument list one-to-one, you have not written a module; you have written a forwarding service. The module's value is its opinions: the subnet layout, the tag set, the naming scheme, the secure defaults — decided once, encoded once.
Defaults carry the opinions. A good module can be called with three or four inputs by a team that trusts its defaults:
variable "az_count" {
type = number
default = 3
description = "How many availability zones to spread subnets across."
validation {
condition = var.az_count >= 2 && var.az_count <= 4
error_message = "az_count must be between 2 and 4 for this network design."
}
}
variable "enable_flow_logs" {
type = bool
default = true # secure by default; turning it off is a decision, not an accident
}
Note the shape: typed variables, a description a stranger can act on, validation that fails at plan time with a human sentence, and defaults that encode the safe choice. Every default you set is a caller who never has to think about that knob — and a team convention that can never be forgotten.
Outputs are the composition layer. Publish the identifiers other modules will need (subnet IDs, the cluster endpoint, the bucket ARN) and nothing else. Outputs are a public API: teams will build on whatever you publish, so publish deliberately, and treat removing an output as a breaking change.
Size: one coherent thing
The temptation is the mega-module — module "platform" that builds the network, the cluster, the databases and the applications in one call. It feels convenient and ages terribly: every consumer inherits every decision, a change to any layer re-plans all of them, and the state file becomes a blast radius the size of your whole estate.
The working rule: a module wraps one layer of the stack that changes together and gets consumed together. Network. Cluster. Data store. Application service. Those compose:
module "network" { source = "./modules/network" /* ... */ }
module "cluster" {
source = "./modules/cluster"
subnet_ids = module.network.private_subnet_ids
}
module "app" {
source = "./modules/app-service"
cluster_id = module.cluster.id
image = "registry.example.com/api:${var.api_version}"
}
Composition through outputs keeps each module independently testable, independently versionable, and independently replaceable — when the cluster design changes, the network module neither knows nor cares. The same thinking, incidentally, is why state management per layer keeps blast radius small: module boundaries and state boundaries want to be the same lines.
Versioning: modules are software, treat them that way
A module consumed from a git repo at ?ref=main is an unpinned dependency on a moving target: every consumer silently upgrades whenever anyone merges anything. The first time that merge changes a default, every environment in the company plans a diff nobody asked for — during somebody's unrelated change window.
The disciplined version:
-
Tag releases semantically.
v1.4.0tells consumers what kind of change they're taking: patch for fixes, minor for additive inputs/outputs, major for breaking changes (renamed variables, changed defaults, removed outputs). -
Pin every reference.
?ref=v1.4.0, never a branch. Upgrading a module becomes a deliberate, one-line, reviewable act per environment — staging takes it this week, production next week, and the diff in between is the changelog. - Keep a changelog. Three honest lines per release beats a generated novel: what changed, who is affected, what to check after upgrading.
-
Test before tagging. A module's test surface is
terraform validate, a plan against a sandbox account, and — for the modules that matter — an ephemeral apply-and-destroy in CI. The registry ecosystem (terraform test, now built in) supports real test runs with mocked providers for the logic-heavy modules. Whatever the depth, a release nobody has planned against is a beta, labelled or not.
Local path modules (./modules/...) skip versioning entirely — fine for one repo's internal layers, where the "consumers" ship in the same commit. The moment a module is consumed from another repo or by another team, it has users, and users deserve versions.
Registry modules: stand on the community carefully
The public registry holds mature modules for the common shapes — the AWS VPC module alone encodes years of edge cases. Using them is good engineering, with two cautions. First, read the interface before committing: a famous module with 200 input variables demands you learn its opinions, which may not be yours. Second, a registry module is still a dependency: pin its version, and re-check its defaults on upgrade (a default flip in a popular module has surprised more than one team). When a registry module fits, it saves months; when it almost fits, a thin in-house wrapper module — your interface on the outside, the registry module inside — keeps your callers insulated from its surface.
Common mistakes
- The god-module. Network + cluster + data + apps in one call: unreviewable plans, maximal blast radius, and consumers forced to accept every layer's choices. Split by layer; compose through outputs.
- Variables that mirror resource arguments. A module whose inputs are the provider's docs restated provides no leverage — the opinions are the product.
-
Unpinned sources.
ref=mainturns every merge into a fleet-wide event. Pin versions; upgrade deliberately. - Everything is an output. Outputs are API. Publishing internals invites consumers to build on them, and then "internal" refactors become breaking changes.
- One module for every environment difference. A variable count that grows with every new consumer is a god-module assembling itself. When a consumer's shape is genuinely different, a second module (or composition of smaller ones) beats a flag for every fork in the road.
- No validation, failure at apply time. An invalid CIDR or an az_count of 7 should fail in seconds at plan, with a sentence explaining why — not fifteen minutes into an apply, midway through creating a network.
FAQ
When should code become a module?
At the second honest reuse — or the first, if the thing is a recognised layer (network, cluster) whose shape you already know. Extracting on first use for everything produces speculative abstractions; waiting until the fourth copy produces archaeology.
Local modules or a shared module repo?
One repo's layers: local paths, shipped together. Modules with consumers in other repos or teams: their own repo (or a registry), versioned and released. The boundary is about who consumes it, not about elegance.
How do I refactor a module without breaking consumers?
The same way you change any API: additively first (new optional variables with defaults, new outputs), deprecate in the changelog, and reserve removals/renames for a major version. Consumers pinned to the old major are untouched until they choose to move.
Modules vs just using Terragrunt/workspaces?
Different layers of the problem. Workspaces and wrapper tools manage environments and repetition at the root level; modules manage reusable components. Most estates of any size end up with both, and the state guidance in the state management guide applies either way. If you're still choosing the IaC tool itself, the Terraform vs Pulumi vs CloudFormation comparison is the wider view.
Conclusion
A Terraform module is a promise: give me these few decisions, and I will give you infrastructure built the way this team agreed — every time, in every environment, from one definition. Keep the interface small and opinionated, compose layers through outputs, version like software, and pin like you mean it. Do that, and the tenth environment costs you an afternoon of variables instead of a week of copy-paste — which was the entire point.
Setting up your first environments? The Kubernetes Production Checklist and the other free resources are on DevOps Enginer — new practical guides are published weekly.
Top comments (0)