Skip to main content

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.

Module:'web-service'\n(server + load balancer +api serviceweb serviceadmin servicename=api, size=largename=web, size=smallname=admin,size=small

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 →