Terraform — Modules and Composition
Modules are how Terraform stays maintainable past a few dozen resources. They’re the “functions” of Terraform — reusable, parameterized units of infrastructure.
What a module is
Any directory of .tf files is a module. The directory you run terraform apply in is the root module; directories it calls are child modules. A module has:
- Inputs —
variableblocks (the parameters). - Resources — what it creates.
- Outputs —
outputblocks (values it exposes to the caller).
# modules/vpc/variables.tf
variable "cidr_block" { type = string }
variable "environment" { type = string }
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
tags = { Environment = var.environment }
}
# modules/vpc/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "subnet_ids" { value = aws_subnet.private[*].id }
Calling it:
# environments/prod/main.tf
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16"
environment = "prod"
}
module "app" {
source = "../../modules/app"
vpc_id = module.vpc.vpc_id # wiring module outputs to module inputs
subnet_ids = module.vpc.subnet_ids
}
Module sources
The source argument can be:
- Local path —
../../modules/vpc— for modules in the same repo. - Terraform Registry —
terraform-aws-modules/vpc/aws— public, versioned, community modules. - Git —
git::https://github.com/org/modules.git//vpc?ref=v1.2.0— your own shared module repo, pinned to a tag. - S3 / artifact — for private distribution.
Always pin a version for registry/git modules (version = "5.1.0" or ?ref=v1.2.0). An unpinned module means an apply next month silently pulls a different module version and changes your infrastructure.
Composition — the layered approach
Don’t write one giant root module. Compose:
modules/ ← reusable building blocks (no environment-specific values)
vpc/
rds/
ecs-service/
alb/
environments/
prod/ ← root module: wires modules together with prod values, prod state
staging/ ← root module: same modules, staging values, staging state
dev/
Each environments/<env>/ is a root module with its own backend/state, calling the shared modules/. The modules contain the how; the environment roots contain the what (sizes, CIDRs, counts).
This gives you: DRY (one module definition, many environments), isolated state per environment (a plan in dev can’t touch prod), and a clear blast radius.
Module design principles
- Single responsibility — a module does one thing (a VPC, an ECS service). Not a “whole platform” module with 40 inputs.
- Sensible defaults, minimal required inputs —
variable "instance_type" { default = "t3.medium" }so callers only override what they need. - Outputs are the contract — expose what callers need to wire together (ids, ARNs, endpoints). Changing or removing an output is a breaking change.
- Don’t over-abstract — a module wrapping a single resource with no added value is just indirection. The rule of thumb: a module earns its keep when it bundles several resources that are always created together, or when it’s used in 3+ places.
- No hardcoded environment values inside the module — environment specifics come in as variables. A module with
environment = "prod"baked in isn’t reusable.
count vs for_each
Two ways to create multiple instances of a resource or module:
# count — indexed; good for "N identical things"
resource "aws_instance" "web" {
count = 3
instance_type = "t3.medium"
}
# addressed as aws_instance.web[0], [1], [2]
# for_each — keyed; good for "a thing per item in a set/map"
resource "aws_instance" "web" {
for_each = toset(["api", "worker", "scheduler"])
instance_type = "t3.medium"
tags = { Role = each.key }
}
# addressed as aws_instance.web["api"], ["worker"], ...
The gotcha: with count, the resources are addressed by index. Remove the middle item from the list and everything after it shifts index → Terraform destroys and recreates them all. With for_each, resources are addressed by key — remove one item and only that one is destroyed; the rest are untouched.
Rule: use count only for genuinely identical, order-independent resources (or a simple on/off count = var.enabled ? 1 : 0). Use for_each whenever the items have identity.
Variables, outputs, locals
variable— module inputs. Give themtype,description, anddefaultwhere sensible. Mark secretssensitive = true.output— module outputs; the public interface.locals— computed values used within the module, not exposed. Good for DRYing up repeated expressions:local.common_tags = { Team = "backend", Env = var.environment }.
variable "db_password" {
type = string
sensitive = true
}
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
Common gotchas
- Unpinned module versions —
applysilently changes when upstream releases. Always pin. countindex shift — removing a non-last list element recreates everything after it. Usefor_eachfor anything with identity.- God modules — 40 inputs, does everything, reused nowhere. Split by responsibility.
- Pointless wrapper modules — one resource, no added logic. Just use the resource.
- Outputs as an unstable contract — renaming an output breaks every caller; treat outputs like a public API.
- Environment values inside modules — kills reusability. Modules take values; environment roots supply them.
- Deep module nesting — modules calling modules calling modules. Two levels (environment root → component module) is usually enough; deeper gets hard to reason about.
Interview angle
- “What’s a Terraform module?” — a directory of
.tffiles with inputs (variable), resources, and outputs (output) — Terraform’s reusable, parameterized unit. The directory you runapplyin is the root module; it calls child modules. - “How do you structure Terraform for multiple environments?” — shared
modules/for reusable building blocks (the how), and per-environment root modules (environments/prod,environments/staging) that wire those modules with environment-specific values and have their own isolated state (the what). - “
countvsfor_each— when each and what’s the trap?” —countfor genuinely identical, order-independent resources (or an on/off toggle).for_eachwhenever items have identity — becausecountaddresses by index, so removing a middle element shifts indices and recreates everything after it;for_eachaddresses by key, so only the removed item changes. - “Why pin module versions?” — an unpinned registry/git module means a future
applysilently pulls a different version and changes your infrastructure. Pin withversion =or?ref=v1.2.0. - “What makes a good module?” — single responsibility, sensible defaults with minimal required inputs, stable outputs as its contract, no environment-specific values baked in, and it bundles resources that are genuinely always created together. A module wrapping one resource with no added value is just indirection.
- “How do you wire modules together?” — one module’s
outputfeeds another module’s input variable:module.apptakesvpc_id = module.vpc.vpc_id. Outputs are the inter-module API.