Terraform #06 — Modules: Design, Versioning & Composition
Build reusable modules the right way: clean input/output interfaces, versioning, registry usage, and composition patterns.
Goal: Design reusable modules the way the industry actually does it — clear input/output interfaces, versioning, composition. Modules are where you can really tell apart someone who's thought carefully about their Terraform from someone who hasn't.
6.1. What is a module?
A module is a directory containing .tf files. Every Terraform directory is a module.
- Root module: the directory where you run
terraform apply. - Child module: a module called by another module (via a
moduleblock).
┌─────────────────────────────────────┐
│ ROOT MODULE │
│ (where you run terraform apply) │
│ │
│ module "network" { ... } ──────────┼──► modules/network/
│ module "database" { ... } ─────────┼──► modules/database/
│ module "app" { ... } ──────────────┼──► registry / git
└─────────────────────────────────────┘
Why use modules?
| Benefit | What it means in practice |
|---|---|
| Reuse | Write once, use for dev/staging/prod and across teams |
| Encapsulation | Hide complex internals behind a simple interface |
| Standardization | Every VPC or DB is created the same way |
| Testability | Test a module in isolation |
| Ownership | Each team owns its modules |
6.2. A well-structured module
modules/vpc/
├── README.md # docs: description, usage example (terraform-docs can generate this)
├── main.tf # primary resources
├── variables.tf # INPUT — the interface
├── outputs.tf # OUTPUT — the interface
├── versions.tf # required_version + required_providers
└── examples/ # usage examples (for testing and docs)
└── basic/
└── main.tf
A module's interface = its variables (input) + outputs (output). Module users only need to understand the interface — they shouldn't need to read
main.tf. Design the interface carefully.
6.3. Calling a module
module "vpc" {
source = "./modules/vpc"
# Pass input variables
vpc_cidr = "10.0.0.0/16"
az_count = 3
environment = "prod"
}
# Use the module's output
resource "aws_instance" "web" {
subnet_id = module.vpc.public_subnet_ids[0]
}
output "vpc_id" {
value = module.vpc.vpc_id # access child output: module.<name>.<output>
}Module meta-arguments
Modules also accept count, for_each, depends_on, and providers:
# Multiple instances of a module
module "team_vpc" {
for_each = toset(["alpha", "beta"])
source = "./modules/vpc"
environment = each.key
}
# Pass a provider alias into a module
module "us_resources" {
source = "./modules/app"
providers = { aws = aws.us_east }
}6.4. Module sources
# 1. Local path (most common when starting out)
source = "./modules/vpc"
source = "../shared/vpc"
# 2. Terraform Registry (public or private)
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
# 3. Git
source = "git::https://github.com/org/repo.git//modules/vpc?ref=v1.2.0"
source = "git::git@github.com:org/repo.git//modules/vpc?ref=v1.2.0"
# 4. GitHub shorthand
source = "github.com/org/repo//modules/vpc?ref=v1.2.0"
# 5. S3 / GCS / HTTP archive
source = "s3::https://bucket.s3.amazonaws.com/vpc.zip"⚠️ Always pin the version with
?ref=(git) orversion =(registry). Unpinned modules can change under you and break production. Use tags/semver, nevermainormaster.
6.5. Module versioning (Semantic Versioning)
Modules should follow SemVer: MAJOR.MINOR.PATCH
| Change type | Bump | Example |
|---|---|---|
| Breaking (remove/rename input, change behavior) | MAJOR | 1.x → 2.0.0 |
| New feature, backwards compatible | MINOR | 1.1 → 1.2.0 |
| Bug fix, no interface change | PATCH | 1.1.0 → 1.1.1 |
Pinning in code:
version = "~> 5.8" # >= 5.8.0, < 6.0.0 (safe, gets minor/patch updates)
version = "= 5.8.1" # locked to exact version
version = ">= 5.8, < 5.10"6.6. Module design principles
Do:
- One module, one clear purpose (VPC, DB, ECS service...). No "swiss army knife" modules that do everything.
- Reasonable defaults on inputs — easy to use out of the box, but still overridable.
- Every variable has a
descriptionandtype. Addvalidationwhen it makes sense. - Export everything the caller might need (id, arn, endpoint...).
- Include
examples/to illustrate usage and enable testing. - Write a README (use
terraform-docsto auto-generate the inputs/outputs table). - A common
tagsvariable that gets merged onto every resource.
Don't:
- Configure providers or backends inside child modules — leave that to the root module.
- Deeply nest modules (module → module → module → module). Hard to debug.
- Create a variable for everything (50+ variables → nobody wants to call your module).
- Hidden side effects (a module that silently creates things unrelated to its stated purpose).
- Undocumented implicit ordering between modules.
Module layering (as it works in practice)
Resource modules (low level) → wrap one group of resources (e.g. one VPC + subnets)
▲
Infrastructure modules → compose multiple resource modules (e.g. full network stack)
▲
Root module (per environment) → compose infra modules + environment-specific config
6.7. Provider configuration in modules
Key rule: Child modules should not have a
providerblock. They only declarerequired_providersinversions.tf. Providers are configured in the root module and passed down (implicitly or viaproviders = {}).
# modules/vpc/versions.tf — CORRECT
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
configuration_aliases = [aws.secondary] # if the module needs an alias
}
}
}
# NO "provider" block here!Why? If a module configures its own provider, you can't use for_each/count with it, and it becomes very hard to reuse.
6.8. Public registry — standing on the shoulders of giants
registry.terraform.io has thousands of high-quality modules. The terraform-aws-modules collection is the most well-known:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
}In practice: use community modules for common things (VPC, EKS, RDS), write your own for business-specific logic. Don't reinvent the wheel — but do read the source of any third-party module before you trust it.
6.9. Hands-on Labs
In hands-on/ you'll find a complete module (modules/app-config) and a root module that calls it. Follow LAB.md:
- Lab 1 — Read through the module structure + call it from the root module.
- Lab 2 — Call the module multiple times using
for_each(multi-env in one apply). - Lab 3 — Change the module's interface and see the effect.
- Lab 4 — (Optional) Use a module from the public registry.
✅ Module 06 completion criteria
- Can explain the difference between root and child modules.
- Can design a module with clear variable/output interfaces.
- Understand why child modules shouldn't have a
providerblock. - Know how to pin module versions correctly (git ref / registry version).
- Can call a module using
for_each. - Know when to use a community module vs writing your own.
➡️ Next: Module 07 — Multi-Environment