Part 3 of 929 min read · 3 diagramsAI-assisted

Modules & Reusable Infrastructure Design

Assumes you're comfortable with Part 1's resource addresses and for_each, and Part 2's state splitting — modules are the other half of "state per service": state decides where configuration's tracked, modules decide how much of it you have to write twice.

Table of Contents#

  1. What a Module Actually Is
  2. The Standard Module File Layout
  3. Variables Are a Module's API — Design Them Like One
  4. Outputs Are a Module's Contract With Its Caller
  5. Provider Inheritance — Implicit vs. Explicit
  6. The for_each-Plus-Provider-Block Incompatibility
  7. Module Composition — Root Modules Calling Child Modules
  8. Nested Modules and the Resource Address Prefix
  9. Where a Module's Source Actually Lives
  10. Semantic Versioning and Pinning Strategy
  11. Publishing to a Registry — Naming and Release Requirements
  12. Cross-Variable Validation and Postconditions
  13. Drawing Module Boundaries — One Module vs. Many
  14. Composition Over Configuration — Avoiding the God-Module
  15. Testing a Module Before It Ships
  16. The Module Release Lifecycle
  17. When Not to Use a Module
  18. Worked Scenario: Extracting the Network Module From Copy-Pasted Code
  19. Worked Scenario: the for_each Plus Provider Alias Bug
  20. Worked Scenario: a "Minor" Module Upgrade That Broke Production
  21. Developing Against an Unpublished Module Version
  22. Part 3 CLI Cheat Sheet
  23. Common Mistakes and Interview Traps
  24. Worked Practice Problems
  25. Summary and What's Next

What a Module Actually Is#

Every Terraform configuration is already a module — the directory you run terraform apply from is the "root module," and any directory referenced via a module block is a "child module." There is no separate mechanism or special syntax for "making a module" — you're organizing configuration you'd otherwise write inline into its own reusable, parameterized unit.

module "network" {
  source = "./modules/network"

  vpc_cidr    = "10.0.0.0/16"
  environment = "prod"
}

Calling a module is structurally identical to calling a resource — a block with a local name, arguments that map to the module's declared variables, and (for outputs) an attribute reference pattern (module.network.vpc_id) that mirrors aws_vpc.main.id. This symmetry is deliberate: from the caller's perspective, a well-designed module should feel like using a slightly more powerful resource type, not like learning a second language.

Diagram

This chapter's caption: the root module owns the overall composition — which child modules exist, and how their outputs feed each other's inputs — while each child module stays focused on one coherent piece of infrastructure.

Note

A module doesn't have its own state file by default — its resources live in whatever state the calling configuration is connected to, with addresses prefixed by module.<name>. (covered in depth below). This is a common early misconception: modules are a code-organization and reuse mechanism, not a state-boundary mechanism the way Part 2's state splitting is.

The Standard Module File Layout#

A consistent file layout — not a Terraform requirement, but a strong ecosystem convention — makes every module in an organization navigable the same way, regardless of who wrote it.

modules/network/ ├── main.tf # Resource definitions (or split further for a complex module) ├── variables.tf # Every input, each with a description and type ├── outputs.tf # Every output, each with a description ├── versions.tf # required_version and required_providers ├── locals.tf # Computed values internal to this module └── README.md # Usage example, generated/kept in sync via terraform-docs

For a module complex enough that one main.tf becomes unwieldy, splitting by purpose (networking.tf, iam.tf, security-groups.tf) beats splitting arbitrarily by resource count — a future reader should be able to guess which file holds what from the filename alone. The convention of naming a module's single primary resource this (resource "aws_db_instance" "this") is worth adopting too: it keeps the resulting address (module.database.aws_db_instance.this) readable without a redundant second name repeating what the module itself is already called.

FileContainsAnalogy
variables.tfEvery input, typed, described, validated where it mattersA function's parameter list
main.tf (or split)The actual resourcesA function's body
outputs.tfEvery value the caller might needA function's return value
versions.tfProvider/Terraform version constraintsA function's declared dependencies
README.mdHuman-readable usage, kept in sync via terraform-docs (Part 1)A function's docstring

Variables Are a Module's API — Design Them Like One#

Every variable block in a module is a public interface decision, not an implementation detail — treat adding, removing, or changing the meaning of one exactly as seriously as changing a public function's signature in a shared library.

variable "vpc_cidr" {
  description = "CIDR block for the VPC — must not overlap with any peered VPC's range."
  type        = string
  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "vpc_cidr must be a valid CIDR block."
  }
}

variable "enable_nat_gateway" {
  description = "Provision a NAT gateway per AZ. Disable for a fully private, cost-sensitive dev environment."
  type        = bool
  default     = true
}

Minimize required inputs, maximize sensible defaults. A module requiring 20 mandatory variables is almost always under-designed — most callers want the common case with a handful of overrides, not a from-scratch specification every time. enable_nat_gateway above is a good example of the pattern: a sensible production default (true), with an explicit, self-documenting override path for the one legitimate case (a cheap dev environment) where the default is wrong.

Tip

Best practice: every variable gets a description, even an internal one only your own team will ever call — beyond helping a human reader, it's the only source terraform-docs (Part 1) has to generate a module's reference documentation from. A variable with no description produces a README row with a blank explanation column, which is worse than no generated docs at all because it looks intentional.

Outputs Are a Module's Contract With Its Caller#

Outputs deserve the same interface discipline as variables — and unlike variables, a removed or renamed output fails silently at the caller's plan time (an "output not found" error), not with any warning at the module's own point of change.

output "vpc_id" {
  description = "VPC ID — pass to any module that needs to launch resources into this network."
  value       = aws_vpc.main.id
}

output "private_subnet_ids" {
  description = "Private subnet IDs, one per AZ, in the same order as var.availability_zones."
  value       = [for s in aws_subnet.private : s.id]
}

Export whole, purposeful values rather than forcing every caller to reconstruct a derived value themselves — private_subnet_ids as a ready-to-use list is a better interface than exposing raw subnet resources and expecting every caller to write the same for expression independently. This is the same "rich output object" guidance Part 2 gave for terraform_remote_state outputs; a module's outputs are read the same way by its caller, so the same discipline applies.

Important

Treat a module's outputs as append-only in practice, the same way you'd treat a public API — adding a new output is always safe, but removing or renaming one is a breaking change for every caller reading it, whether that caller is a sibling module block in the same configuration or, per Part 2, another state entirely via terraform_remote_state. A module's own version number (covered later in this chapter) is exactly the mechanism that should bump on a change like this.

Provider Inheritance — Implicit vs. Explicit#

Provider configuration works fundamentally differently for modules than variables and outputs do — a child module cannot declare its own provider block (only a root module can) and gets its provider configuration from its caller through one of exactly two mechanisms.

# In the root module:
provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

module "network" {
  source = "./modules/network"
  # Implicit inheritance: the default (non-aliased) "aws" provider
  # is automatically available inside this module — no wiring needed.
}

module "network_west" {
  source = "./modules/network"
  providers = {
    aws = aws.west   # Explicit: an aliased provider is NEVER inherited automatically.
  }
}
MechanismWhen it appliesExplicit config needed?
Implicit inheritanceThe module uses the caller's default (non-aliased) providerNo — automatic
Explicit providers mapThe module needs an aliased provider configurationYes — always, no exceptions

This split matters practically the moment a module needs to provision into more than one region or account — the module itself stays region-agnostic (it never hardcodes region = "us-west-2" anywhere), and the caller decides which concrete provider configuration to hand it via the providers map, keeping the module fully reusable across however many regions/accounts the organization eventually needs.

The for_each-Plus-Provider-Block Incompatibility#

A module containing its own provider block cannot be called with count, for_each, or depends_on — a real, sharp-edged Terraform limitation that surfaces as a confusing error the first time someone hits it, and a strong argument for the "child modules never declare providers" rule stated above.

# modules/region-stack/versions.tf — DON'T DO THIS in a child module
provider "aws" {
  region = var.region
}
# Root module — this WILL fail
module "region_stack" {
  source   = "./modules/region-stack"
  for_each = toset(["us-east-1", "us-west-2"])
  region   = each.key
}
Error: Module does not support for_each The module at module.region_stack is a legacy module which contains its own provider configurations, and so calling it using for_each is not allowed.

The fix is exactly what the earlier sections already recommend: strip the provider block out of the child module entirely, and drive multi-region calls from the root module instead — either multiple named module blocks each passed a differently-aliased provider, or (in Terraform releases and OpenTofu that support it) a for_each-driven set of provider configurations at the root. This is one of the clearest cases in the whole language where "a module should never configure its own provider" isn't just a style preference — it's the only way to keep for_each/count available for that module at all.

Warning

This restriction bites hardest on a module that was written early, before the team needed multi-region support, and worked fine as a single-region module with its own convenient provider block for months. Moving to for_each for genuine multi-region provisioning later forces a real refactor, not a config tweak — bake "child modules never contain a provider block" into your module review checklist from day one to avoid this exact rewrite.

Module Composition — Root Modules Calling Child Modules#

A well-composed root module reads like an assembly instruction, not a wall of raw resources — its whole job is wiring child modules' outputs into each other's inputs, with as little raw resource logic of its own as the composition genuinely needs.

module "network" {
  source      = "./modules/network"
  vpc_cidr    = "10.0.0.0/16"
  environment = "prod"
}

module "checkout_database" {
  source = "./modules/database"

  vpc_id             = module.network.vpc_id
  subnet_ids         = module.network.private_subnet_ids
  identifier         = "checkout-prod"
  instance_class     = "db.r6g.large"
}

module "checkout_eks_node_group" {
  source = "./modules/eks-node-group"

  cluster_name = module.eks.cluster_name
  subnet_ids   = module.network.private_subnet_ids
  labels       = { workload = "checkout" }
}

Reading top to bottom, this root module tells the whole provisioning story for checkout-service's infrastructure without needing to open any child module's internals — network provisions first (nothing else depends on it), then checkout_database and checkout_eks_node_group both consume its subnet outputs in parallel (Terraform's graph, per Part 1, figures this parallelism out automatically from the references).

Tip

Best practice: keep the root module's own resource count near zero for anything beyond genuinely orchestration-only glue. A root module accumulating dozens of raw resource blocks alongside its module calls is a sign some of that logic belongs inside a dedicated module of its own instead.

Nested Modules and the Resource Address Prefix#

Every resource inside a child module gets its address prefixed with module.<name>., and this prefixing nests indefinitely — a resource three modules deep has a three-segment prefix, which matters directly for terraform state mv (Part 2) and for reading plan output on a deeply composed configuration.

module.network.module.subnets.aws_subnet.private["us-east-1a"]

This example address says: the root module calls module.network, which itself calls a nested module.subnets, which contains an aws_subnet.private resource keyed by AZ. Reading a plan for a deeply nested configuration means reading these prefixes carefully — 1 to change buried three modules deep in a 50-resource plan is easy to skim past if you're not used to parsing the full address.

Note

There's no hard limit on nesting depth, but each additional layer makes plan output harder to scan and a module's true dependency chain harder to reason about. Two levels (root → module → occasionally a nested module inside that) covers the overwhelming majority of real designs; reaching for a third or fourth level habitually is usually a sign the module boundaries themselves need rethinking, per the next section.

Where a Module's Source Actually Lives#

Part 1 already covered source address forms for consuming a module (registry, git, local path) — the practical question this chapter adds is which form to use for which kind of module, since the three forms carry genuinely different tradeoffs for a growing organization.

Source formUpdate mechanismBest for
./modules/network (local path)Immediate — same commit as the callerA module that only ever makes sense inside this one repo
git::https://...?ref=v2.3.1Explicit ref bump, reviewed as a normal PRAn internal module shared across multiple repos, without publishing infrastructure
A private registry (HCP Terraform, or a self-hosted one)version constraint, resolved automaticallyAn internal module published for genuinely org-wide reuse, with real semantic versioning
The public registry (terraform-aws-modules/...)version constraintA well-maintained community module for a common, non-differentiated need (a VPC, an EKS cluster skeleton)

A local path is the right default for anything genuinely single-repo. The moment a module is consumed from two or more repositories, a local path stops working, and the choice becomes git-ref-pinned vs. a real registry — the registry option is worth the setup cost once more than a couple of teams depend on the same module, purely because version = "~> 2.3" reads and enforces far more clearly in a PR diff than a git commit SHA or tag ref does.

Semantic Versioning and Pinning Strategy#

A module published for reuse should follow semantic versioning exactly the way a library does — a major bump for any breaking interface change (a removed/renamed variable or output, a changed default that alters real behavior), a minor bump for a backward-compatible addition, a patch bump for a fix with no interface change at all.

module "network" {
  source  = "app.terraform.io/meridian-platform/network/aws"
  version = "~> 2.3"
}

The ~> operator from Part 1 applies identically here — ~> 2.3 allows 2.3.x and 2.4.x but not 3.0.0, which is exactly the boundary a well-versioned module's major-version bump is supposed to signal as "something here might require a code change on your end, read the changelog before upgrading." A module publisher who doesn't take this discipline seriously — bumping only patch versions for changes that are actually breaking — makes every consumer's ~> pin meaningless, which is why the review-and-changelog habit from Part 1's registry section applies with even more force to internal modules your own team owns.

Tip

Best practice: for an internal module, write and maintain a CHANGELOG.md alongside version bumps — especially the "why" behind a major version, not just "what changed." A consumer deciding whether to upgrade needs to know if a breaking change affects their specific usage, which a diff alone often doesn't make obvious.

Publishing to a Registry — Naming and Release Requirements#

Both the public Terraform Registry and HCP Terraform's private registry enforce a specific repository naming convention and release process — getting this right the first time avoids a frustrating "why won't this publish" cycle.

Repository names must follow terraform-<PROVIDER>-<NAME> exactly — terraform-aws-network, not aws-network-module or network-terraform-aws — where <PROVIDER> is the module's primary target provider and <NAME> (which may itself contain hyphens) describes what it provisions. The module must follow the standard file layout from earlier in this chapter (the registry's own documentation generator depends on finding variables.tf/outputs.tf in the expected shape), and at least one semantically-versioned release tag (v1.0.0, or 1.0.0 — the v prefix is optional but must be consistent) must exist before the first publish.

git tag v1.0.0
git push origin v1.0.0
# Then, in the registry UI: "Upload" -> select the matching terraform-aws-network repository
RequirementPublic registryPrivate (HCP Terraform) registry
Repository namingterraform-<PROVIDER>-<NAME>, mandatorySame convention strongly recommended
Repository visibilityMust be publicCan be private
Release mechanismGit tags matching semverGit tags matching semver
DocumentationAuto-generated from variables.tf/outputs.tf/README.mdSame

Tip

Best practice: tag and publish a module's very first v0.1.0 as soon as it has a working example, even before every planned feature lands — an early, real version number gives consumers something concrete to pin against immediately, rather than everyone temporarily pointing at an unpinned branch ref "until it's ready," which is exactly the kind of temporary arrangement that quietly becomes permanent.

Cross-Variable Validation and Postconditions#

Terraform 1.9+ allows a variable's validation block to reference other variables, not just itself — closing a real gap where a module's inputs are individually valid but mutually inconsistent.

variable "min_size" {
  type = number
}

variable "max_size" {
  type = number
  validation {
    condition     = var.max_size >= var.min_size
    error_message = "max_size must be greater than or equal to min_size."
  }
}

Before 1.9, this kind of cross-variable check required a workaround — a precondition inside a resource or data block's lifecycle, checked only at plan/apply time against that specific resource, not as a clean upfront input check. Native cross-variable validation catches the mistake immediately, with a clear error message, before Terraform even attempts to build a plan.

Postconditions solve a related but different problem — asserting something about a resource's state after it's created, not about its inputs before:

resource "aws_instance" "checkout" {
  # ...
  lifecycle {
    postcondition {
      condition     = self.public_ip == null
      error_message = "checkout instances must never receive a public IP — check subnet configuration."
    }
  }
}
MechanismRuns whenChecks
variable { validation {} }Before planning, against raw input values"Are the inputs individually and mutually sane?"
lifecycle { precondition {} }Before a resource/data block is evaluated"Is it safe to even attempt this?"
lifecycle { postcondition {} }After a resource is created/read"Did the real result actually meet the contract I expect?"

Tip

Best practice: use a postcondition on any resource where a specific outcome matters more than the configuration that's supposed to produce it — the checkout instance example above catches a misconfigured subnet (missing the map_public_ip_on_launch = false setting from this chapter's earlier module-upgrade scenario) at apply time with a clear, specific error, instead of only being caught later by a security scanner or, worse, an actual exposure incident.

Drawing Module Boundaries — One Module vs. Many#

The single most common module-design mistake is either one module per resource (too granular, all the composition burden pushed onto every caller) or one giant module covering an entire environment (too coarse, Part 2's monolithic-state problem reborn as a monolithic-module problem).

Diagram

A useful heuristic: a module should represent one coherent, independently-deployable-in-concept piece of infrastructure with a real name a platform engineer would say out loud — "the network module," "the database module," "the EKS node group module" — not "the module that wraps aws_instance" (too granular) and not "the checkout-service module" covering VPC-through-application-in-one-block (too coarse, and it recreates exactly the blast-radius problem Part 2 spent a whole chapter fixing at the state layer).

SignalSuggests
A module has exactly one resource and no real logic around itToo granular — inline it, or fold it into a sibling module
A module requires 15+ variables to configurePossibly too coarse — look for a natural sub-boundary
Two different services need genuinely different configurations of "the same" moduleThe module's variable interface may need more optional() flexibility, not a fork
A module's README needs several paragraphs just to explain what it provisionsLikely too coarse — split along the boundary the explanation is already drawing

Composition Over Configuration — Avoiding the God-Module#

A module that grows a boolean flag for every possible variant ("enable_nat_gateway," "enable_vpc_endpoints," "enable_flow_logs," "use_ipv6," ...) eventually becomes harder to reason about than several smaller, composed modules would have been — this is the infrastructure-module version of the "god object" anti-pattern from application design.

# Anti-pattern: one module trying to be everything to everyone
module "network" {
  source                = "./modules/network"
  enable_nat_gateway     = true
  enable_vpc_endpoints   = true
  enable_flow_logs       = false
  enable_transit_gateway = true
  use_ipv6               = false
  # ... 12 more toggles
}
# Composition: each concern is its own focused module, wired together explicitly
module "network" {
  source   = "./modules/network"
  vpc_cidr = "10.0.0.0/16"
}

module "vpc_endpoints" {
  source = "./modules/vpc-endpoints"
  vpc_id = module.network.vpc_id
}

module "flow_logs" {
  source = "./modules/flow-logs"
  vpc_id = module.network.vpc_id
}

The composed version costs a few more lines in the root module, and buys back something genuinely valuable: each piece can be tested, versioned, and reasoned about independently, and a caller who doesn't need VPC endpoints simply never calls that module — no dead, unused-but-still-evaluated toggle sitting in their configuration.

Tip

Best practice: reach for a boolean toggle inside a module for a genuinely minor variant (Part 1's count = var.enable_feature ? 1 : 0 pattern for a single optional resource), and reach for composition — a separate, focused module — once a module accumulates enough toggles that reading its full variable list no longer tells a reader clearly what it actually does by default.

Testing a Module Before It Ships#

Part 7 goes deep on Terraform testing broadly; the module-specific version of that discipline worth flagging here is that a module intended for reuse should ship with its own example configuration (examples/basic/) that's also a real, runnable smoke test — not just documentation.

modules/network/ ├── main.tf ├── variables.tf ├── outputs.tf ├── examples/ │ └── basic/ │ └── main.tf # A minimal, real call to this module — doubles as living documentation └── tests/ └── network.tftest.hcl

terraform test (native, Terraform 1.6+) run from within the module directory against its own tests/ files is the mechanism that turns "does this module actually work" from a manual, pre-release ritual into something CI runs on every PR, before a version is ever tagged and published for other teams to consume.

The Module Release Lifecycle#

Pulling every practice in this chapter together, a healthy internal module's release process has a consistent shape from first change to a consumer safely upgrading.

Diagram

This chapter's caption: the version-bump decision happens once, at tag time, based on the actual nature of the change — everything downstream (registry publish, consumer changelog review, the eventual upgrade) depends on that one decision being made honestly, which is exactly why the earlier "read the changelog, not just the version diff" guidance matters as much on the publishing side as the consuming side.

When Not to Use a Module#

Not every repeated pattern justifies extraction into a module — a genuinely useful counter-question to this whole chapter, worth asking before reaching for module out of habit.

SituationExtract a module?Why
The same 3-resource pattern appears in exactly 2 places, with no plan to grow beyond thatUsually noThe indirection cost (a caller now has to learn the module's interface) can exceed the savings for genuinely small, stable duplication
A pattern is copy-pasted across 3+ services and drifting (Part 3's own opening scenario)YesDrift risk and duplicated-fix burden both grow with copy count — exactly the motivating case for this chapter
A resource's configuration is deeply specific to one service and unlikely to ever be reusedNoA module wrapping a single, non-reusable resource adds a layer of indirection with no real payoff
An external team needs to consume the same infrastructure patternYesA module is the only mechanism for sharing configuration across repository/team boundaries at all

Note

A small amount of deliberate duplication is a legitimate design choice, not automatically technical debt — the actual cost to weigh is "how often will this drift or need a coordinated fix across every copy" against "how much does learning and maintaining a shared module's interface cost." Two genuinely stable, unlikely- to-diverge copies of a 5-line resource block rarely justify the overhead a module adds.

Worked Scenario: Extracting the Network Module From Copy-Pasted Code#

Before this chapter's practices existed, checkout-service, catalog-service, and inventory-service each had their own VPC provisioning code — three near-identical, independently-drifted copies of roughly the same 80 lines, each with small, undocumented differences nobody could confidently explain (one had a enable_dns_hostnames toggle the others lacked; nobody remembered why).

The extraction process: diff all three copies to find the actual variance (not the incidental differences — different CIDR ranges, same otherwise), parameterize exactly that variance as module variables, and default every genuinely-shared value so most future callers only need to set 2-3 arguments. The moved block mechanism from Part 6 made the migration itself non-destructive — each service's existing VPC resources were re-addressed under module.network.aws_vpc.main without a single resource being destroyed and recreated, despite the underlying .tf files changing substantially.

moved {
  from = aws_vpc.main
  to   = module.network.aws_vpc.main
}

Note

This scenario is the concrete reason Part 6 exists as its own chapter — extracting a module from existing, already-applied resources is one of the single most common real-world reasons a team reaches for moved blocks, and getting it wrong (skipping moved, letting Terraform destroy-and-recreate three production VPCs during a "simple refactor") is exactly the kind of mistake this series is built to prevent.

Worked Scenario: the for_each Plus Provider Alias Bug#

A newer platform engineer, building the multi-region extension for chapter 9's disaster-recovery work, copied an existing single-region eks-node-group module and added a provider "aws" { region = var.region } block directly inside it — reasoning that this would let the module "just work" for whichever region it was pointed at. The very next attempt to call it with for_each across two regions failed at terraform plan with exactly the "does not support for_each" error from earlier in this chapter.

The fix followed the pattern already established: strip the provider block from the child module, move provider configuration (two aliased aws providers, aws.east and aws.west) to the root module, and pass the correct one explicitly via each module block's providers map. The engineer's instinct — "let the module own its region" — was reasonable on its face, but it collided directly with a hard Terraform constraint that only becomes visible the moment for_each/count enters the picture, which is exactly why this chapter states the "no provider blocks in child modules" rule as a hard review-checklist item rather than a soft suggestion.

Worked Scenario: a "Minor" Module Upgrade That Broke Production#

The team bumped terraform-aws-modules/vpc/aws from ~> 5.1 to ~> 5.4 in a routine dependency-update PR, reviewed as a one-line version bump with no other changes. The module's own changelog, unread before merging, noted that 5.3 had changed a previously-hardcoded default for map_public_ip_on_launch from true to false on public subnets — a genuinely sensible security-hardening default for new users, and a silent behavior change for anyone who had relied on the old default and never set the argument explicitly.

The resulting plan on the next apply showed a change to every public subnet's map_public_ip_on_launch attribute — an in-place update, not a destroy, so it wasn't immediately alarming, but it would have quietly broken auto-assigned public IPs for a public-facing load balancer's subnet if it had been applied without review. The plan-review discipline from Part 1 caught it before apply; the actual process fix afterward was mandating a changelog read (not just a version-number diff) for any public-registry module bump, exactly the same "review the plan, not just the code diff" discipline Part 1 established for resource-level changes, applied one layer up at the module-dependency level.

Developing Against an Unpublished Module Version#

Before a module change is tagged and published, a consumer needs a way to test against the in-progress version — pointing source at a local checkout temporarily is the standard technique, with one important discipline around not letting the temporary override reach a real commit.

module "network" {
  # Temporarily testing an unreleased change — points at a local clone
  # of the module repo instead of the pinned registry version.
  source = "../terraform-aws-network"
  # source  = "app.terraform.io/meridian-platform/network/aws"
  # version = "~> 2.3"
}

Commenting out the registry source/version pair rather than deleting it keeps the eventual "switch back" a one-line change instead of retyping the pin from memory. Some teams formalize this further with a gitignored override.tf (Terraform natively supports _override.tf files that merge into and take precedence over matching blocks in the main configuration) specifically for this kind of local, never- committed source swap — worth adopting once "test against an unreleased module change" becomes a routine part of a team's workflow rather than a rare exception.

Warning

Never commit a local-path source override for a module that's meant to consume a published, versioned release — it silently breaks reproducibility for every other teammate and for CI, both of which expect the pinned registry version to exist at their own checkout path (or, worse, not to exist at all on a CI runner that never cloned the module repository as a sibling directory).

Part 3 CLI Cheat Sheet#

CommandPurpose
terraform get -updateRe-download module sources without a full init
terraform state list | grep module.networkList every resource address inside one module
terraform plan -target=module.networkScope a plan to one module (break-glass only, per Part 2)
terraform-docs markdown table .Generate a module's input/output reference from its code
terraform test (run inside a module directory)Run the module's own tests/*.tftest.hcl files
terraform providersConfirm which providers a configuration (and its modules) actually require

Common Mistakes and Interview Traps#

MistakeWhy it's wrongCorrect approach
Putting a provider block inside a child moduleBlocks for_each/count/depends_on on any call to that moduleConfigure providers only in the root module; pass aliased providers explicitly via providers = {}
Treating a module's outputs as free to renameAny caller (a sibling module, or terraform_remote_state) reading it breaks silently at their own plan timeTreat outputs as append-only; version-bump (major) for any rename or removal
One module per single resourcePushes all composition burden onto every caller, with no real reuse benefitGroup resources that are genuinely provisioned/versioned together into one focused module
A module accumulating a boolean toggle for every possible variantBecomes a "god module" that's hard to reason about or test in isolationPrefer composing several focused modules for genuinely distinct concerns
Bumping a public module's version without reading its changelogA version bump can carry a silent default-behavior change, not just new featuresRead the changelog, not just the version diff, before merging any module version bump
Nesting modules three or four levels deep out of habitPlan output and dependency reasoning both get harder to follow with each layerReach for a third nesting level only when the boundary genuinely needs it, not by default

Worked Practice Problems#

Problem 1: A child module contains a provider "aws" { alias = "east" ... } block, and its caller tries for_each = toset(["a", "b"]). What happens, and what's the fix?

Answer: terraform plan fails with a "module does not support for_each" error — any provider block inside a child module (aliased or not) makes that module incompatible with for_each, count, and depends_on. The fix is removing the provider block from the child module entirely and configuring/aliasing the provider in the root module instead, passing it into each call explicitly (or per-for_each-key, in Terraform/OpenTofu versions supporting that) via the providers argument.

Problem 2: A module's outputs.tf removes an output named subnet_ids in the same PR that adds a better-named private_subnet_ids, with no version bump and no changelog note. What breaks, for whom, and when do they find out?

Answer: Every caller of this module currently referencing module.network.subnet_ids breaks — not at the moment of this PR merging, but the next time their own configuration runs terraform plan, when Terraform reports the referenced output no longer exists. This is exactly the "silent breaking change" risk the chapter's outputs-as-contract section warns about: the failure surfaces far from its cause, at a delay, and for someone who had no visibility into the module's own PR at all — which is why a major version bump and a changelog entry (not just a rename in the diff) are the actual fix, giving consumers a signal to look for before their next upgrade, rather than a surprise mid-plan.

Problem 3: A team has one 400-line module covering an entire environment's networking, database, and application-tier infrastructure together, with 22 input variables. A new service needs the same networking and database pattern but a completely different application-tier setup. What's the actual design problem, and what's the fix?

Answer: The module conflates three genuinely distinct concerns (networking, database, application tier) into one unit, forcing every caller to accept or override all 22 variables even when they only need two of the three concerns. The fix is splitting it into three focused modules — network, database, and application-tier — composed together in the root module for services that want all three, while the new service composes only network and database and builds its own application-tier configuration independently. This is the "drawing module boundaries" heuristic from earlier in the chapter applied directly: a coarse module that different callers only partially want is a strong, concrete signal it should be split along the boundary the differing needs are already drawing.

Problem 4: A module's variables.tf declares min_size and max_size for an autoscaling group, with no relationship enforced between them. A caller accidentally sets max_size = 2 and min_size = 5. What happens without cross-variable validation, and how does adding it change the failure mode?

Answer: Without validation, both values pass individually (each is a valid number), and the module proceeds to create an aws_autoscaling_group with an internally inconsistent configuration — AWS itself will likely reject the API call or produce confusing scaling behavior, and the resulting error surfaces deep in a provider-level failure with a stack trace that doesn't obviously point back to "these two numbers don't make sense together." A validation block cross-referencing var.max_size >= var.min_size on the max_size variable (per this chapter's cross-variable validation section) catches this immediately, at terraform plan time, with a clear, module-authored error message pointing at the actual mistake — turning a confusing provider-level failure into an obvious, immediate one.

Summary and What's Next#

A module is nothing more than a directory of configuration with a parameterized interface — the discipline that makes modules valuable is treating that interface (variables in, outputs out) with the same rigor as a shared library's public API: minimal required inputs, sensible defaults, append-only outputs, and real semantic versioning enforced by actually reading changelogs, not just version numbers. Provider configuration is the one place modules genuinely can't be self-contained — child modules never own a provider block, both because it's cleaner design and because Terraform enforces it the moment for_each enters the picture. Drawing the right boundary (one coherent concern per module, composed rather than configured into a single god-module) is the design judgment call every other practice in this chapter serves.

Part 4 builds directly on this chapter's composition patterns to answer a related but distinct question: not how configuration is organized (modules), but how the same configuration gets deployed safely into multiple environments and accounts — workspaces, directory-per-environment layouts, and the multi-account patterns that keep a mistake in staging from ever having a path to reach production. Every module this chapter taught you to design will, by the end of that chapter, be called from several different directories at once — the interface discipline built here is what makes that reuse safe rather than fragile, not an afterthought bolted on once the module already has real callers depending on it.