Terraform Modules: How to Structure Reusable Infrastructure
Practice4 minInnovialabs editorial team
In short
A good Terraform module wraps one coherent piece of infrastructure behind a small set of typed inputs and useful outputs, is versioned and pinned, and is called from thin per-environment root configurations with separate state.
The short answer
A module in Terraform is just a folder of .tf files. Every configuration is already a root module; reusable modules are folders you call with a module block.
A good module:
- describes one coherent thing: a network, a database with its backups, a service with its load balancer;
- exposes a few typed inputs with sensible defaults;
- returns outputs that other parts of the stack actually need;
- is versioned, so environments upgrade on purpose, not by accident.
Environments (dev, staging, prod) are thin root configurations that call the same modules with different values and keep separate state.
Inputs, outputs and the module contract
# modules/postgres/variables.tf
variable "name" {
type = string
description = "Instance name prefix"
}
variable "instance_size" {
type = string
default = "small"
}
variable "backup_retention_days" {
type = number
default = 7
validation {
condition = var.backup_retention_days >= 1
error_message = "Retention must be at least one day."
}
}
# modules/postgres/outputs.tf
output "endpoint" {
value = aws_db_instance.this.address
}
output "password_secret_arn" {
value = aws_secretsmanager_secret.db.arn
sensitive = true
}
Treat variables and outputs as a public API:
- Use explicit
typeanddescription; addvalidationfor values that are easy to get wrong. - Prefer one object variable over a dozen loosely related ones only when the fields really belong together.
- Mark secrets
sensitive = true, and remember they still end up in state, so protect the state backend. - Do not configure
providerblocks inside reusable modules. Let the caller pass providers in.
Versioning
Calling a module from a Git repository or a registry lets you pin a version:
module "db" {
source = "git::https://github.com/acme/terraform-modules.git//postgres?ref=v1.3.0"
name = "shop-prod"
instance_size = "large"
backup_retention_days = 14
}
- Tag releases with semantic versions: breaking changes to inputs or resource addresses mean a new major version.
- Upgrade dev first, then staging, then prod, reviewing
terraform planeach time. - When you rename resources inside a module, add
movedblocks so Terraform updates addresses instead of destroying and recreating. - Pin provider versions with
required_providersand commit the lock file.
A sensible repository layout
infra/
modules/
network/
postgres/
app-service/
envs/
dev/
main.tf
backend.tf
terraform.tfvars
staging/
prod/
- Each folder in
envs/is its own root with its own state and backend config. A mistake in dev cannot touch prod state. - Differences between environments live in variables, not in copy-pasted resources.
- Split large environments into a few stacks (network, data, apps) when plans become slow or risky. Pass values between them through outputs and data sources.
- Workspaces can work for near-identical environments, but separate folders make differences and access control more visible.
Using registry modules safely
Public registry modules save time, but you run someone else's code with your cloud credentials.
- Prefer modules from verified or well-maintained publishers with active issues and releases.
- Always pin an exact version; never rely on the latest.
- Read the source and the plan: what resources and IAM permissions does it create?
- Check that defaults suit you, especially encryption, public access and logging.
- For critical infrastructure, consider forking or mirroring the module into your organization.
Anti-patterns
- Over-abstracted modules: a "universal" module with dozens of flags that can build any resource. It is harder to read than plain resources and every change risks every user.
- Wrapper modules that only rename one resource's arguments and add nothing.
- Deep nesting: modules calling modules several levels down make plans hard to understand.
- Copy-pasted environments that drift apart over time.
- Hardcoded values like region, account IDs or CIDRs inside a reusable module.
- One giant state for everything, so every apply touches the whole company.
A good rule: write a module when you have at least two real uses for it, or when it encodes a standard that must be applied consistently.