This is the third post in our Terraform fundamentals series. Catch up on providers and state and variables and outputs if you're joining partway through.
By the time a Terraform codebase provisions more than one environment, a pattern shows up in almost every team that hasn't adopted modules yet: three folders — dev, staging, production — each containing a nearly-identical copy of the same .tf files, differing only in a handful of variable values. Someone fixes a security group rule in production under deadline pressure, and six months later nobody remembers whether staging ever got the same fix.
A module is Terraform's answer to that copy-paste problem: a self-contained, parameterized bundle of resources that gets called — not copied — from wherever it's needed.
What a module actually is
Every Terraform configuration you've written so far has technically already been a module — specifically, the root module, the one Terraform runs directly. A child module is the same idea, just called from somewhere else instead of run directly.
Structurally, a module is just a directory with its own .tf files:
modules/
web-server/
main.tf
variables.tf
outputs.tf
# modules/web-server/variables.tf
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "environment" {
type = string
}
variable "ami_id" {
type = string
}
# modules/web-server/main.tf
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = "web-${var.environment}"
Environment = var.environment
}
}
# modules/web-server/outputs.tf
output "instance_id" {
value = aws_instance.web.id
}
output "public_ip" {
value = aws_instance.web.public_ip
}Nothing here looks different from a root configuration — that's intentional. The only new concept is how you call it.
Calling a module
module "web_dev" {
source = "./modules/web-server"
environment = "dev"
ami_id = "ami-0c1a7f89451184c8b"
instance_type = "t3.micro"
}
module "web_production" {
source = "./modules/web-server"
environment = "production"
ami_id = "ami-0c1a7f89451184c8b"
instance_type = "m5.large"
}Two calls, two independent instances, defined in one place. Fix a bug inside modules/web-server/main.tf, and both callers get the fix the next time someone runs terraform apply — no copy-paste, no risk of one environment silently missing a change the other received.
Referencing a module's output from the calling configuration uses module.<name>.<output>:
output "dev_ip" {
value = module.web_dev.public_ip
}Where module sources actually come from
source = "./modules/web-server" is a local path, ideal while a module is still specific to one project. As modules mature into things shared across repositories or teams, source also accepts:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}That's the Terraform Registry — a public catalog of community and vendor-maintained modules. For genuinely standard infrastructure (a VPC with sensible subnet layouts, an EKS cluster, an RDS instance with reasonable defaults), reaching for a well-maintained registry module is usually a better use of time than writing the equivalent from scratch — it's already handled the edge cases you haven't hit yet.
source also accepts a Git URL directly, which is the common pattern for internal, organization-specific modules that don't belong in the public registry:
module "internal_service" {
source = "git::https://github.com/your-org/terraform-modules.git//service?ref=v1.4.0"
}Always pin ref to a tag or commit SHA, not a branch name. ref=main means the module's behavior can change underneath you the next time anyone runs terraform init, with no corresponding change in your own repository's history to explain why.
Designing a module worth reusing
Not every resource block deserves to become a module. A module is worth the extra structure when it represents a genuinely reusable unit — "a web server," "a VPC," "an S3 bucket configured to our security baseline" — not an arbitrary grouping of whatever happened to be in one file.
A few design habits separate modules that stay useful for years from ones that get abandoned after the first awkward edge case:
- Expose configuration through variables, not by making callers edit the module. If a caller needs to reach into a module's internals to get the behavior they want, the module's interface is incomplete.
- Keep required variables to the minimum that's genuinely different per caller. Everything else should have a sensible default. A module with twenty required variables is barely more convenient than no module at all.
- Output everything a caller might plausibly need next, even if the first caller doesn't use it yet. Adding an output later is a safe, non-breaking change; a caller working around a missing output usually means reaching into resource internals the module was supposed to hide.
- Version it, once more than one project depends on it. A shared module without version tags means every consumer is exposed to every change, immediately, whether they wanted it or not.
What to actually remember from this post
- A module is a parameterized, callable bundle of resources — the fix for maintaining near-duplicate copies of the same configuration.
- The Terraform Registry is usually the right starting point for standard infrastructure; write your own module for what's genuinely specific to your organization.
- Pin module versions and Git refs, the same way you pin provider versions — for the same reason.
- A good module's interface is its variables and outputs — everything else should be free to change internally without breaking callers.
Next in the series: Managing Terraform State in Teams, where local state — the thing we deliberately left as a loose end in the first post — finally gets fixed properly.
