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
.tffiles with its ownvariableinputs andoutputs — there's nothing to register, and the directory you runapplyfrom 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/stagingandenvs/prodwith 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 insidemodules/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
variableinputs 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
sourceat?ref=main(or no ref at all). Fix: a branch ref means everyinitcan 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'sinitor a CI runner'sinitreproduce 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
sourceaddress 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.