Skip to content

Modules

Copy the same 40 lines of VPC-and-subnet HCL from envs/staging into envs/prod, and the two will drift the moment someone fixes a bug in one copy and forgets the other. The course page introduces modules as "a directory of .tf files" and shows one root calling two children; this page slows down on why you split config into modules at all, the interface contract (inputs/outputs) that makes a module reusable, how modules compose into a tree, the difference between a local module and a registry module, and the versioning discipline that keeps a shared module safe to change.

TL;DR — in 30 seconds:

  • A module is just a directory of .tf files with its own variable inputs and outputs — there's nothing to register, and the directory you run apply from is itself the root module.
  • Encapsulation is the whole point: a child module only sees what's passed in as variables, and the caller only sees what's exposed as outputs — no local, bare attribute, or provider config leaks either direction.
  • A registry or Git module source needs a version pin (version = "5.1.0" or a Git tag ?ref=v1.4.0) — a moving branch ref reintroduces the same non-determinism state exists to prevent.

1. What a module actually is

A module is just a directory containing .tf files. There's nothing to declare or register — any directory with Terraform config in it is a valid module, including the directory you run terraform apply from (the root module). Calling module "network" { source = "./modules/network" } from the root creates a child module: a separate, encapsulated scope with its own resources, its own variable and output blocks, and no automatic visibility into the caller's locals or resources.

That encapsulation is the entire point. A child module can only see what the caller passes in as arguments, and the caller can only see what the child exposes as outputs. Nothing else leaks either direction — not a local, not a bare resource attribute, not a provider configuration (providers have their own passing rule, covered in section 5).

flowchart TB
    Root["Root module<br/>(where you run apply)"]
    Net["module \"network\"<br/>(child)"]
    Store["module \"storage\"<br/>(child)"]
    Root -->|"inputs: variables"| Net
    Net -->|"outputs"| Root
    Root -->|"inputs: variables"| Store
    Store -->|"outputs"| Root
    Net -.->|"vpc_id output"| Store

2. Why split config into modules at all

A single flat main.tf works fine until it doesn't — usually around the point you need the same VPC-plus- subnets shape in two environments, or a second engineer needs to touch storage without reading every line of networking. Modules solve two different problems, and it's worth naming them separately:

  • Reuse. Write the network topology once as a module; call it from envs/staging and envs/prod with different inputs. Without a module, that's copy-pasted HCL that drifts the moment one copy gets a fix the other doesn't.
  • Composition and readability. Even with no reuse in sight, a root module that calls module "network", module "storage", module "database" reads as a table of contents. The 200 lines of subnet math live inside modules/network/ where only someone changing subnet math needs to look.

Both benefits come from the same mechanism: a module is a function over inputs that produces resources and outputs. Think of variable blocks as parameters, the resources inside as the function body, and output blocks as the return value.

3. The interface contract — inputs and outputs

A child module declares what it needs and what it gives back; the caller supplies the first and reads the second:

# modules/network/variables.tf — the module's parameters
variable "name"       { type = string }
variable "cidr_block" { type = string }
variable "azs"        { type = list(string) }

# modules/network/outputs.tf — the module's return value
output "vpc_id"            { value = aws_vpc.this.id }
output "private_subnet_ids" { value = [for s in aws_subnet.private : s.id] }
# root main.tf — the call site
module "network" {
  source     = "./modules/network"
  name       = local.name_prefix
  cidr_block = var.vpc_cidr
  azs        = local.azs
}

# Reading an output — always module.<name>.<output>
resource "aws_security_group" "web" {
  vpc_id = module.network.vpc_id
}

Two disciplines make this contract worth having:

Discipline What it means Why it matters
No hidden inputs Everything the module needs arrives via variable. No implicit terraform.workspace reads, no environment variables read inside the module. A caller can predict a module's behavior entirely from the inputs it passes — no surprise state.
Useful outputs Expose what callers plausibly need (vpc_id, subnet_ids, security_group_id), not just what you happened to use internally. Callers shouldn't have to fork the module to read one more attribute.

A rough size hint: a module under ~30 lines of HCL is often premature abstraction — you've paid the indirection cost without a matching reuse or clarity payoff. A module over ~800 lines is usually doing two jobs and should split.

4. Composition — modules calling modules

Modules nest. A root module can call a child that itself calls further children, and a module block can even come from an entirely different source. Cross-module references (module B needs module A's output) are ordinary expressions — Terraform builds the dependency graph from them automatically, so declaration order in the file doesn't matter:

module "network" {
  source     = "./modules/network"
  cidr_block = var.vpc_cidr
}

module "database" {
  source = "./modules/database"
  # Referencing another module's output creates the dependency;
  # Terraform provisions network before database without being told to.
  vpc_id     = module.network.vpc_id
  subnet_ids = module.network.private_subnet_ids
}

The practical ceiling on nesting isn't technical — it's how many layers a reader can hold in their head while tracing "where does this value actually come from." Two or three levels (root → network → subnet) is normal; five is usually a sign the tree should be flattened.

5. Sourcing — local path, registry, and Git

The source argument tells Terraform where to fetch a module, and the three you'll meet constantly are:

Source form Example When to use it
Local path source = "./modules/network" Module lives in this repo; fastest, no external fetch, always in sync with the caller.
Public/private registry source = "terraform-aws-modules/vpc/aws" A well-maintained community or org-published module; pin with a separate version argument.
Git source = "git::https://github.com/example/tf-modules.git//network?ref=v1.4.0" Shared modules that don't live in a registry; the ?ref= is mandatory (see below).

The Terraform Registry hosts thousands of community modules (the terraform-aws-modules org's VPC, EKS, and RDS modules are the ones you'll reach for most on AWS). Using one trades "review 30 lines of your own HCL" for "review someone else's module and trust its maintenance" — worth it for genuinely undifferentiated infrastructure (a VPC is a VPC), less worth it for anything with org-specific requirements baked in.

6. Versioning — pinning what you don't control

A local module changes exactly when your own commit changes it — no separate versioning needed. A registry or Git module is different: someone else's next release can change behavior under you, so pinning is not optional:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.0"    # exact version — registry modules use semver constraints like "~> 5.1"
  # ...
}

module "network" {
  source = "git::https://github.com/example/tf-modules.git//network?ref=v1.4.0"  # tag, never a moving branch
}

The rule for Git sources is stricter than for registry ones: always ?ref= a tag, never ?ref=main or no ref at all. A tag is immutable once pushed; a branch ref means every terraform init can silently pull in whatever the module's maintainer merged since your last run — the same non-determinism problem state exists to prevent, reintroduced through the module supply chain instead of the resource graph.

terraform init resolves whatever version/ref you pinned and records the exact result in .terraform.lock.hcl — committing that file is what makes a second init (a teammate's machine, a CI runner) reproduce the identical module and provider versions rather than re-resolving and potentially drifting.

7. How this connects

The course page's module "network" / module "storage" call is the composition pattern this page slows down on: why the split exists (reuse and readability), what a caller may and may not see across the boundary (inputs in, outputs out, nothing else), and the two extra disciplines — sourcing and version pinning — that only bite once a module comes from outside your own repo.

Common gotchas

  • Assuming a child module can read the caller's locals or resources directly. Fix: only variable inputs cross that boundary — nothing else leaks either direction, not even a provider configuration (which has its own passing rule).
  • Building a one-off, ~10-line module "for reuse" with no second caller in sight. Fix: a module under ~30 lines is often premature abstraction — you've paid the indirection cost with no matching reuse or clarity payoff yet.
  • Pointing a Git module source at ?ref=main (or no ref at all). Fix: a branch ref means every init can silently pull in whatever the maintainer merged since your last run — always pin a tag, which is immutable once pushed.
  • Not committing .terraform.lock.hcl. Fix: that's what makes a teammate's init or a CI runner's init reproduce the exact module/provider versions you resolved, instead of re-resolving and potentially drifting.
Check yourself — inside modules/network, can you reference a local value defined in the root module that called it?

No — a child module has no automatic visibility into the caller's locals, resources, or provider configuration. The only way a value crosses the boundary inward is as a variable the caller explicitly passes.

Check yourself — your root module reads module.network.vpc_id in a second module's arguments. Do you need to add a depends_on to make network provision first?

No — referencing another module's output is itself the dependency. Terraform builds the graph from that reference automatically and provisions network before the module that reads its output, with no explicit ordering needed.

You can defend this when you can explain what a module can and cannot see from its caller, name the two reasons to split config into modules, read a source argument and say whether it needs a version pin, and explain why a Git module source should never point at a branch.

Go deeper: the full module system — hierarchy, sourcing, and the develop/publish workflow — is in the Terraform modules overview; the complete set of source address forms (registry, Git, HTTP archives, and more) is in the module block reference — this page covers the 80/20 you need for the Do exercise.