TerraformXIV · Modules: Reusable Building BlocksModules
Modules: Reusable Building Blocks
What you'll learn
- Call a module and read its inputs and outputs
- Design a module with a useful interface
- Distinguish good module abstraction from premature abstraction
- Source modules from the registry, Git, and local paths
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
A module is a Terraform configuration that can be called from another configuration. Modules are how Terraform codebase reuse works. The lesson teaches the structural rules, the production patterns, and the traps of premature abstraction.
What a module is
A module is a directory containing Terraform configuration
files. The root module is the configuration the user is
applying. Child modules are directories referenced by a
module block.
my-terraform/
├── main.tf # root module
├── variables.tf
├── outputs.tf
├── versions.tf
├── modules/
│ └── network/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── versions.tf
└── environments/
├── production/
│ └── main.tf # calls both modules
└── staging/
└── main.tf
A module is just a directory. The convention of splitting
into main.tf, variables.tf, outputs.tf, and versions.tf
is common but not required. Terraform itself does not care
about the filenames.
Calling a module
A module block calls a child module:
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
environment = var.environment
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
subnet_id = module.network.public_subnet["a"].id
}
The source argument declares where the module is. The other
arguments are the modules inputs. The modules outputs are
accessible via module.<name>.<output>.
Module inputs
Module inputs are variables declared in the modules
variables.tf:
# modules/network/variables.tf
variable "vpc_cidr" {
type = string
description = "The CIDR block for the VPC."
}
variable "environment" {
type = string
description = "The environment name (dev, staging, prod)."
}
variable "availability_zones" {
type = list(string)
description = "List of availability zones to span."
default = ["us-east-1a", "us-east-1b"]
}
The module caller passes values for these variables. The module defines the interface; the caller consumes it.
Module outputs
Module outputs are declared in the modules outputs.tf:
# modules/network/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
description = "The ID of the created VPC."
}
output "public_subnets" {
value = aws_subnet.public
description = "The public subnets, keyed by availability zone."
}
The outputs are accessible to the caller via
module.network.public_subnets. The module is the boundary;
the caller cannot see the resources inside the module unless
the module exposes them via outputs.
The good module interface
A good module interface:
- Has a clear purpose. One module = one responsibility. “Compute module”, “network module”, “DNS module” — not “infrastructure module”.
- Has a small number of inputs. A module with 30 inputs is a code smell; the configuration is too clever.
- Has a small number of outputs. A module that exposes every underlying resource is a thin wrapper, not a module.
- Has documented inputs and outputs. The
descriptionfield is mandatory in production. - Has validation on inputs. A
cidr_blockinput should validate that the value is a valid CIDR.
# Good: focused module
module "vpc" {
source = "./modules/vpc"
name = "production"
cidr_block = "10.0.0.0/16"
region = "us-east-1"
}
# Less good: god module
module "production_infrastructure" {
source = "./modules/production_infrastructure"
vpc_cidr = "10.0.0.0/16"
vpc_name = "production"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.11.0/24"]
database_subnets = ["10.0.20.0/24", "10.0.21.0/24"]
instance_type = "t3.medium"
instance_count = 5
database_engine = "postgres"
database_version = "15"
database_password = var.db_password
cluster_name = "production-cluster"
log_retention_days = 30
# ... 30 more inputs
}
The “good” module does one thing. The “less good” module does everything, and adding a new requirement means adding a new input.
The drift and state boundary
A module is also a state boundary. Every resource declared inside a module is in the same state as the caller. The state records the absolute resource address within the modules namespace:
module.network.aws_vpc.main
module.network.aws_subnet.public["a"]
module.network.aws_subnet.public["b"]
module.network.aws_subnet.private["a"]
The module path is part of the address. When you mv a
resource between modules, the address changes, and Terraform
treats it as a different resource.
The state boundary implications:
- A module is a unit of state partitioning. Splitting a configuration into modules does not split the state by default; the state is still one file.
- Module refactoring without state handling is destructive.
Moving a resource from
module.networktomodule.netrequires amovedblock. The course has a dedicated lesson on this. - The modules variables are inputs, not state. The module cannot read the parents variables without explicit passing.
Module sources
A module can be sourced from:
Local path
module "network" {
source = "./modules/network"
}
Used for tightly-coupled modules within the same repository.
Git
module "network" {
source = "git::https://github.com/example/terraform-modules.git//network?ref=v1.2.3"
}
The ref argument pins the version. Without ref, the module
tracks the default branch (usually main), which is dangerous
in production.
Public registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
The version is a constraint. The ~> operator means “compatible
release” — ~> 5.0 includes 5.x but not 6.0.
Private registry
module "vpc" {
source = "app.terraform.io/my-org/vpc/aws"
version = "~> 5.0"
}
A private registry with Terraform-specific access controls.
Other sources
The Terraform documentation lists sources for S3, GCS, Bitbucket, GitHub, and Mercurial. Each source has its own quirks.
The modules versions.tf
A common pattern is to declare the modules version constraints
in a versions.tf file:
# modules/network/versions.tf
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
The constraints travel with the module. The terraform init of
the parent will fail if the parents terraform version does
not satisfy the modules required_version.
Module versioning
For Git-sourced modules, the version is a Git tag:
source = "git::https://github.com/example/terraform-modules.git//network?ref=v1.2.3"
The recommended workflow:
- Develop on a branch.
- Create a tag when the module is ready (
git tag v1.2.3). - Update the consumers
refto the new tag. - The consumers next
terraform initdownloads the new version. - The consumers
terraform planshows the impact.
The lock file records the resolved version:
# .terraform.lock.hcl
module "network" {
source = "git::https://github.com/example/terraform-modules.git//network"
version = "1.2.3"
}
This is the reproducibility contract for the module.
The trap of premature abstraction
A common pattern is to wrap a single resource in a module:
# modules/instance/main.tf
resource "aws_instance" "main" {
ami = var.ami
instance_type = var.instance_type
tags = var.tags
}
This module is a wrapper, not a module. It has no value over declaring the resource directly:
# Without the module
resource "aws_instance" "web" {
ami = var.ami
instance_type = var.instance_type
tags = var.tags
}
The module becomes useful when:
- The same pattern is repeated across multiple configurations.
- The pattern has production-tested defaults.
- The pattern has its own lifecycle (e.g. a
null_resourcethat triggers alocal-execwhen the underlying resource is recreated).
The course has a dedicated lesson on the distinction between helper modules and reusable abstractions.
What comes next
The next lesson is module structure — the conventions for organising a modules files.
Knowledge check · 7 questions
Q1. What is a module?
Q2. What is the role of module inputs?
Q3. Modules should be tracked from main branch.
Q4. What is a god module?
Q5. Which of the following are characteristics of a good module interface? (Select all that apply.)
Q6. What is the role of module versions?
Q7. A module upgrade changes a variable default. The plan proposes to recreate many resources. What is the fix?
Passing score: 75%. Answers are checked in this browser.