Terraform — Part 10 — Modules: Building Reusable Infrastructure
Every example so far has lived in one flat configuration. This part covers packaging a group of resources into a module — something callable more than once, with its own inputs and outputs, instead of copy-pasted between environments.
.tf files with its own inputs and outputs — every configuration in this series has technically already been one, the root module, without being called from anywhere else.
The configuration in your working directory — every example in this series so far.
A separate directory of resources, called from elsewhere via a module block.
Where a module’s code comes from — a local path, a registry, a Git repository.
Introduction
Every configuration written so far in this series lives in one directory, run directly. That’s technically already a module — Terraform calls it the root module — it just hasn’t been called from anywhere else. The moment the same group of resources needs to exist twice, in two environments or for two teams, copy-pasting that configuration is exactly the kind of duplication Part 1 argued against in the first place.
What a Module Is
A module is simply a directory containing .tf files — resources, variables, outputs, whatever a normal configuration has. What makes it reusable is calling it from another configuration with its own set of inputs, the same way a function takes parameters rather than having its logic copy-pasted everywhere it’s needed.
The module Block
module "pet_server" {
source = "./modules/pet-server"
pet_length = 2
}
source is required and tells Terraform where to find the module’s code — covered in detail below. Any other argument in the block is passed to the module as a value for one of its declared input variables, exactly like setting a variable’s value from a .tfvars file back in Part 5. A version constraint can also be set, but only applies when the module comes from a registry rather than a local path.
Building a Simple Module
A module follows the same basic file convention most published Terraform modules use: main.tf for the resources themselves, variables.tf for its inputs, and outputs.tf for what it exposes back to whatever calls it.
Turning Part 5’s variable-plus-local-plus-output example into a reusable module, in modules/pet-server/variables.tf:
variable "pet_length" {
type = number
description = "Number of words in the generated pet name."
default = 2
}
In modules/pet-server/main.tf:
resource "random_pet" "server" {
length = var.pet_length
}
resource "local_file" "hello" {
filename = "${path.module}/hello.txt"
content = "Server name: ${random_pet.server.id}"
}
And in modules/pet-server/outputs.tf:
output "pet_name" {
description = "The generated pet name used as the server identifier."
value = random_pet.server.id
}
${path.module} refers to that module’s own directory, not the root configuration’s directory — this matters the moment a module gets called from somewhere other than right next to it.
Calling the Module Twice
This is where a module earns its keep. Calling it once for each environment, with a different input each time, needs no copy-pasted resource blocks at all:
module "pet_server_dev" {
source = "./modules/pet-server"
pet_length = 2
}
module "pet_server_prod" {
source = "./modules/pet-server"
pet_length = 3
}
Each call creates its own independent copy of everything inside the module — its own random_pet, its own local_file, tracked separately in state.
Referencing a Module’s Outputs
A module’s declared outputs are read from the calling configuration with module.<LABEL>.<OUTPUT NAME>:
output "dev_pet_name" {
value = module.pet_server_dev.pet_name
}
This is the same dot-notation reference pattern from every earlier part in this series, one more level deep — module.pet_server_dev reaches into the module call, and .pet_name reaches the specific output it declared.
Module Sources
source accepts several formats depending on where the module actually lives:
| Source Type | Example |
|---|---|
| Local path | ./modules/pet-server |
| Public registry | hashicorp/consul/aws |
| GitHub | github.com/example-org/example-repo |
| Git (general, with a ref) | git::https://example.com/vpc.git?ref=v1.2.0 |
| HTTP archive | https://example.com/module.zip |
A double slash marks a subdirectory within a larger repository or package, useful when a module lives alongside other, unrelated modules in the same repo: git::https://example.com/repo.git//modules/vpc?ref=v1.2.0.
ref or version on any module source that isn’t a local path. An unpinned Git or registry source can resolve to different code on a future init without your configuration itself changing at all.
Standard Module Layout
Published modules — and any module worth reusing across more than one project — generally follow the same layout: main.tf, variables.tf, and outputs.tf at the module’s root, a README.md explaining its purpose and usage, and for a repository containing several modules, each one living under its own subdirectory inside modules/.
Quick Reference Summary
| Term | Meaning |
|---|---|
| Root module | The configuration in the working directory Terraform is run from. |
| Child module | A separate module called via a module block. |
source |
Required argument telling Terraform where a module’s code lives. |
module.LABEL.OUTPUT |
Reference syntax for reading a called module’s output. |
${path.module} |
The current module’s own directory, distinct from the root configuration’s directory. |
Final Thoughts
A module doesn’t introduce any new HCL concept — it’s the same blocks from every earlier part in this series, just organised into a callable unit with declared inputs and outputs. What changes is that the same infrastructure can now be stamped out consistently, instead of relying on someone copying a configuration correctly by hand.
Terraform — Part 11 — Managing Environments: Workspaces vs Directories. We cover the two common patterns for running the same configuration against dev, staging, and production without them colliding.