Terraform · Part 10

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.

Quick idea: a module is just a directory of .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.
Root module

The configuration in your working directory — every example in this series so far.

Child module

A separate directory of resources, called from elsewhere via a module block.

source

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
}
Important: inside a module, ${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.

Practical rule: pin a 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.

Key takeaway: reach for a module the moment the same group of resources needs to exist more than once — it’s the same discipline as Part 1’s case against click-ops, applied to your own configuration instead of manual infrastructure changes.
Next in this series

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.