Terraform Modules
A module is a directory of Terraform files treated as one unit. Every configuration is already a module (the root module), and it can call child modules to reuse infrastructure patterns: a standard VPC, a hardened S3 bucket, a Kubernetes cluster with company defaults. Modules are to Terraform what functions and libraries are to application code. They package complexity behind inputs and outputs so it's written once and reused everywhere.
Well-designed modules are how platform engineering teams offer self-service infrastructure with guardrails built in. Poorly designed ones become wrappers with a hundred pass-through variables that nobody can upgrade.
TL;DR
- A module is a folder of
.tffiles with variables (inputs), resources, and outputs. - Call modules with a
moduleblock and asource(a local path, the registry, Git, or object storage). - Pin versions (
version = "~> 5.4"for registry modules,?ref=v1.2.0for Git). - Use
for_eachon module blocks to stamp out several instances. - Give variables types, descriptions, defaults, and
validationblocks; output what callers need. - Prefer small, focused, composable modules over one mega-module with endless flags.
Quick Example
A small module for a private, encrypted S3 bucket:
Every bucket created through the module gets public-access blocking and versioning automatically.
Core Concepts
Module Structure
The conventional layout:
Child modules shouldn't configure providers themselves. They declare required_providers, and the root module passes provider configuration in (implicitly, or via providers = { aws = aws.us_west } for aliases).
Inputs, Outputs, and Locals
- Variables have
type(includingobject({...})withoptional()attributes),default,description,sensitive,nullable, andvalidationblocks. - Outputs expose values to callers: IDs, ARNs, endpoints. Only output what consumers need; outputs form the module's API.
- Locals hold intermediate computed values inside the module.
Module Sources and Versions
Local paths (./modules/x) aren't versioned; they change with the repo. Registry and Git sources should always pin a version or tag so upgrades are deliberate.
Composition
Keep modules shallow and compose them in the root module, passing one module's outputs into another's inputs:
Deeply nested modules (modules calling modules calling modules) are hard to debug and to refactor.
Designing Good Modules
- Encode opinions. A module's value is in its defaults: encryption on, public access off, tags required. A thin wrapper exposing every argument of one resource adds indirection without value.
- Keep the interface small. Five well-chosen inputs beat fifty flags. Group related settings into object variables with
optional()defaults. - One purpose per module. "Network", "database", and "service" as separate modules compose better than one "environment" module.
- Version with semver and keep a changelog. Removing a variable or renaming a resource address is a breaking change for callers; use
movedblocks inside the module to keep upgrades safe (see Terraform state). - Test modules with
terraform testagainst example configurations. See Terraform testing.
Best Practices
Use Well-Maintained Public Modules for Commodity Infrastructure
Community modules like terraform-aws-modules/vpc encode years of edge cases. Pin a version, read the changelog before upgrading, and wrap them only if you need to enforce organization defaults.
Validate Inputs Early
validation blocks, precondition/postcondition checks on resources, and precise types catch mistakes at plan time with clear error messages instead of cryptic provider errors mid-apply.
Prefer for_each Over count
for_each keys instances by stable names (module.buckets["logs"]), so removing one doesn't shift the others. With count, removing index 0 renumbers everything and can trigger destroy/create churn.
Document With Examples
A README with a minimal and a complete example, plus generated input and output tables (terraform-docs), makes modules usable without reading their source.
Common Mistakes
Pass-Through Wrapper Modules
Either add real value (defaults, companion resources, policy), or use the resource directly.
Unpinned Module Versions
A registry module without version, or a Git source without ref, pulls whatever is latest on the next init, and can change infrastructure without any code change on your side.
Configuring Providers Inside Child Modules
Modules containing provider blocks can't be used with for_each/count and can't be removed cleanly, because Terraform needs the provider to destroy their resources. Configure providers in the root module and pass them down.
FAQ
When should I create a module?
When the same group of resources appears in several places, or when you want to enforce a standard (security defaults, tagging, naming) across teams. Don't modularize a resource used once "for tidiness". Extract when duplication or policy makes it worthwhile.
How do I version internal modules?
Put them in a dedicated repo (or monorepo path) and tag releases with semver (v1.4.0), referenced via ?ref=. Or publish them to a private registry (HCP Terraform, GitLab, Artifactory) for version constraints and discovery.
Can modules have their own state?
No. A module's resources live in the state of the root configuration that calls it. To isolate state, use separate root configurations that call the same module.
What's the difference between a module and a workspace?
A module is reusable code. A workspace is a separate state instance for the same root configuration. You might use one root configuration (calling many modules) with workspaces for dev and staging, or separate root directories per environment calling shared modules.
Related Topics
- Terraform — The tool overview
- Terraform State —
movedblocks and refactoring modules safely - Terraform Testing — Testing modules with
terraform test - Terraform Providers — Passing providers into modules
- Platform Engineering — Modules as golden paths
- Infrastructure as Code — Reuse and standardization