Modules: Composition, Not Copy-Paste
This chapter covers writing and consuming Terraform modules, the unit of reuse for infrastructure.
Why Modules
After a few projects, you'll notice patterns. Every service needs: a VPC, a subnet per AZ, a security group, an RDS instance, an ALB. Copy-pasting a 500-line config between projects is a bad time. A module is what you paste once and call many times.
A module is a directory with .tf files. Inside, you define variables (inputs), resources, and outputs (exposed values). The caller passes variables and consumes outputs.
The Structure of a Module
Conventional layout:
modules/
vpc/
main.tf # resources
variables.tf # inputs
outputs.tf # exposed values
versions.tf # required_providers
README.md # docs
A trivial module:
modules/notes-bucket/variables.tf:
variable "environment" {
type = string
description = "Environment name (dev/staging/prod)."
}
variable "retention_days" {
type = number
default = 30
description = "Number of days before objects expire."
}
modules/notes-bucket/main.tf:
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "this" {
bucket = "notes-${var.environment}-${random_id.suffix.hex}"
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
id = "retention"
status = "Enabled"
expiration {
days = var.retention_days
}
}
}
modules/notes-bucket/outputs.tf:
output "bucket_name" {
value = aws_s3_bucket.this.bucket
}
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}
Consuming a Module
Call it with a module block:
module "dev_notes" {
source = "./modules/notes-bucket"
environment = "dev"
}
module "prod_notes" {
source = "./modules/notes-bucket"
environment = "prod"
retention_days = 365
}
output "dev_bucket_name" {
value = module.dev_notes.bucket_name
}
sourcetells Terraform where to find the module code.- Other arguments are the module's input variables.
- Reference outputs as
module.<name>.<output>.
Every module call creates independent resources. dev_notes and prod_notes are two distinct S3 buckets.
Module Sources
source can point to a few places.
Local Path
source = "./modules/notes-bucket"
source = "../shared/modules/vpc"
Use for modules inside the same repo. Simplest. No versioning; the code in the path is the code you get.
Terraform Registry
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
Public registry at registry.terraform.io. terraform-aws-modules/vpc/aws is namespace/name/provider. Pin the version.
Some of the most useful community modules:
terraform-aws-modules/vpc/aws VPC, subnets, NAT, route tables
terraform-aws-modules/rds/aws RDS instance with sane defaults
terraform-aws-modules/eks/aws EKS cluster with node groups
terraform-aws-modules/security-group/aws Security group with common patterns
Read the docs. Community modules can have hundreds of inputs.
Git
source = "git::https://github.com/your-org/terraform-modules.git//modules/vpc?ref=v1.2.0"
Private or custom modules. ref pins to a tag, branch, or commit.
Terraform Private Registry
source = "app.terraform.io/your-org/vpc/aws"
version = "~> 1.0"
For HCP Terraform / Terraform Enterprise. Same semantics as public registry, private access.
S3, HTTP, Archive
Terraform supports S3, HTTP, and tarball sources. Rare in practice.
Versioning
Always pin the version for non-local sources:
version = "5.5.0" # exact
version = "~> 5.5" # 5.5.x, not 5.6+
version = ">= 5.0, < 6.0" # range
Without a pin, you get "the latest version", which can change under you. That's a debugging nightmare when a module author ships a breaking change.
Treat module versions like dependency versions in any language: pin, upgrade deliberately, review the changelog.
Writing Good Modules
A module should have a clear purpose, sensible defaults, and no surprises.
One Job
"A VPC module" makes sense. "An entire application platform" module doesn't. Smaller modules compose better.
Sensible Defaults
Every required variable is a friction point. Default what you reasonably can, require only what the caller must know.
variable "enable_flow_logs" {
type = bool
default = false
description = "Enable VPC flow logs."
}
Good: sensible off-by-default. Opt in to complexity.
Limited Outputs
Only expose outputs consumers need. Every output is part of the module's API; changing it later is a breaking change.
Document the Inputs
description on every variable. README with examples. Terraform can auto-generate docs (terraform-docs); use it.
Don't Wrap for No Reason
A module that wraps a single resource and exposes every argument is worse than using the resource directly. Modules should add value: composition, defaults, sensible names, encapsulation.
Module Composition
Modules can call other modules. A "platform" module might compose a VPC module, a database module, and a Kubernetes module:
# modules/platform/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# ...
}
module "db" {
source = "./modules/db"
vpc_id = module.vpc.vpc_id
}
module "k8s" {
source = "./modules/eks"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
}
Composition is where modules shine. Build up from small pieces.
for_each on Modules
You can use for_each on module calls, same as resources:
module "env_bucket" {
for_each = toset(["dev", "staging", "prod"])
source = "./modules/notes-bucket"
environment = each.key
}
Creates three instances of the module. Reference outputs with module.env_bucket["dev"].bucket_name.
Module Testing
Two approaches, covered in Chapter 10:
terraform test(built into Terraform 1.6+): write test cases in HCL that validate outputs and apply behavior.- Terratest (Go library): real integration tests that deploy, assert, and destroy.
For reusable modules, test them. Other engineers will trust (or blame) you based on whether your module works as advertised.
The Public Registry
When reaching for a module:
- Check the Terraform registry. If a community module exists and is maintained, use it.
- Prefer
terraform-aws-modules(for AWS) over one-off repos. Maintained, widely used, opinionated in consistent ways. - Read the README. Check the examples directory. Understand what gets created.
- Pin a version.
Don't build your own VPC module for one service. The community one is better.
Anti-Patterns
Tiny modules that wrap one resource. module.s3_bucket for a single bucket doesn't add value. Use the resource.
Kitchen-sink modules. "The module that does everything" has fifty inputs and does none of them well. Break it up.
Versioning too aggressively. If you publish module v2.0 weekly, consumers can't keep up. Version on meaningful changes.
Unversioned sources. source = "git::...?ref=main" means "latest main", which can change under you. Pin to a tag or commit.
Shared provider config inside modules. Modules should receive providers from the caller (implicitly or via providers argument). Declaring providers in modules makes them awkward to compose.
Common Pitfalls
Missing version on registry source. Latest-by-default breaks when the module author ships breaking changes.
Module path mistakes. source = "./modules/vpc" is relative to the calling config, not the module's own location.
Expecting module.x.resource.y. You can't reference a module's internal resources. Only its outputs. Plan accordingly.
Modules with implicit dependencies on provider config. A module that assumes region = "us-east-1" breaks in another region. Pass what it needs.
Next Steps
Continue to 07-remote-backends.md to share state safely across a team.