backend / iac / terraform / 02_modules_and_composition.md

Terraform — Modules and Composition

6 interview angles 5 min read source

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:

  • Inputsvariable blocks (the parameters).
  • Resources — what it creates.
  • Outputsoutput blocks (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 Registryterraform-aws-modules/vpc/aws — public, versioned, community modules.
  • Gitgit::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 inputsvariable "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 them type, description, and default where sensible. Mark secrets sensitive = 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 versionsapply silently changes when upstream releases. Always pin.
  • count index shift — removing a non-last list element recreates everything after it. Use for_each for 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 .tf files with inputs (variable), resources, and outputs (output) — Terraform’s reusable, parameterized unit. The directory you run apply in 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).
  • count vs for_each — when each and what’s the trap?”count for genuinely identical, order-independent resources (or an on/off toggle). for_each whenever items have identity — because count addresses by index, so removing a middle element shifts indices and recreates everything after it; for_each addresses by key, so only the removed item changes.
  • “Why pin module versions?” — an unpinned registry/git module means a future apply silently pulls a different version and changes your infrastructure. Pin with version = 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 output feeds another module’s input variable: module.app takes vpc_id = module.vpc.vpc_id. Outputs are the inter-module API.