Modules & composition: don't repeat infrastructure
A single .tf file is fine for one bucket. But real systems have dozens of resources, and you often need the same shape repeated — a "web service" pattern (server + load balancer + security group) deployed for three different apps, or an identical environment for staging and production. Copy-pasting blocks is exactly the kind of repetition that causes drift and bugs. The answer is modules: reusable, parameterized packages of infrastructure. This lesson shows how to stop repeating yourself.
What a module is
A module is a folder of Terraform files that defines a reusable piece of infrastructure, with inputs (variables you pass in) and outputs (values it gives back). It's the infrastructure equivalent of a function in programming: define the logic once, then call it many times with different arguments to get many instances.
Everything you've written so far has actually been a module — the top-level one, called the root module. A reusable module is just the same idea factored into its own folder so you can call it repeatedly.
One definition, three deployments — each customized by its inputs. Fix a bug or improve the module once, and every instance benefits.
A module's anatomy
A module folder conventionally has three files:
variables.tf— the inputs: what the caller must (or may) provide.main.tf— the resources: the actual infrastructure, written in terms of those inputs.outputs.tf— the outputs: values the module hands back to its caller.
A tiny "tagged bucket" module might be:
# modules/app-bucket/variables.tf
variable "app_name" { type = string }
variable "environment" { type = string }
# modules/app-bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = "${var.app_name}-${var.environment}-assets"
tags = {
app = var.app_name
env = var.environment
}
}
# modules/app-bucket/outputs.tf
output "bucket_name" {
value = aws_s3_bucket.this.bucket
}
Calling a module
You use a module with a module block, passing its inputs — just like calling a function with arguments:
module "billing_assets" {
source = "./modules/app-bucket" # where the module lives
app_name = "billing"
environment = "production"
}
module "reports_assets" {
source = "./modules/app-bucket"
app_name = "reports"
environment = "production"
}
# Reference a module's output elsewhere:
output "billing_bucket" {
value = module.billing_assets.bucket_name
}
Two calls, two buckets (billing-production-assets and reports-production-assets), from one definition. If you later need every app bucket to enable versioning, you change the module once and both pick it up. That's the whole payoff.
:::tip DRY applied to infrastructure "DRY" — Don't Repeat Yourself — is a core software principle, and modules bring it to infrastructure. Repetition is where bugs and drift breed: copy-paste five security groups and you'll eventually fix four of them and forget the fifth. A module makes the pattern exist in exactly one place. The durable rule: if you've written the same infrastructure shape twice, it probably wants to be a module. :::
Composition: building environments from modules
The real power shows when you compose modules into whole systems and environments. A common, durable layout:
- A set of focused modules:
network(the VPC and subnets),database,web-service. - A thin per-environment root that calls those modules with environment-specific inputs.
# environments/production/main.tf
module "network" {
source = "../../modules/network"
cidr = "10.0.0.0/16"
}
module "database" {
source = "../../modules/database"
subnet_ids = module.network.private_subnet_ids # wire modules together
size = "large" # production sizing
}
Staging looks identical but with size = "small" and a different network range. Production and staging now come from the same modules, differing only by inputs — which means they're truly alike, eliminating the "works in staging, breaks in prod" class of bugs that hand-built environments suffer. This is the IaC dream realized: environments as code, composed from shared, reviewed building blocks.
:::note Public modules — reuse the community's work You don't have to write every module. There are public registries of pre-built, maintained modules for common patterns (a standard VPC, a Kubernetes cluster). Using a well-maintained public module saves enormous effort — but vet it like any dependency (you're trusting it with your infrastructure). (Which specific registry/modules are best is dated; the practice of reusing modules is durable.) :::
Why it matters
A module is a reusable, parameterized package of infrastructure — infrastructure's version of a function — with inputs and outputs, letting you define a pattern once and instantiate it many times. Modules bring the DRY principle to infrastructure, concentrating each pattern in one place so fixes and improvements propagate everywhere and repetition-driven drift disappears. By composing focused modules (network, database, web-service) into thin per-environment roots, you build staging and production from the same building blocks, differing only by inputs — finally making environments genuinely identical. You can now structure infrastructure at real scale. The last IaC lesson steps back to the ecosystem — Pulumi and the alternatives — and when each fits.
Next: Pulumi & alternatives →