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
}Passing complex configuration into a module
Real modules rarely take three flat string variables. Combined with the object types covered in the previous post, a module's interface can express genuinely structured configuration:
# modules/web-server/variables.tf
variable "server_config" {
type = object({
instance_type = string
disk_size_gb = number
monitoring = optional(bool, false)
})
}
variable "subnet_ids" {
type = list(string)
description = "Subnets to spread instances across"
}module "web_production" {
source = "./modules/web-server"
server_config = {
instance_type = "m5.large"
disk_size_gb = 100
monitoring = true
}
subnet_ids = module.vpc.private_subnet_ids
}That last line — subnet_ids = module.vpc.private_subnet_ids — is worth noticing specifically: one module's output feeding directly into another module's input. This is how real Terraform codebases compose: a vpc module produces networking primitives, a web-server module consumes them, and neither module needs to know how the other is implemented internally, only its variable and output interface.
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, and it's been exercised by far more real-world configurations than anything you'd write in a week.
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.
Composing modules: modules that call other modules
Nothing stops a module from calling another module internally — this is how larger, opinionated building blocks get built out of smaller, more general ones:
# modules/three-tier-app/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
cidr = var.vpc_cidr
}
module "web_servers" {
source = "../web-server"
subnet_ids = module.vpc.private_subnet_ids
count = var.instance_count
}
module "database" {
source = "../rds-postgres"
subnet_ids = module.vpc.database_subnet_ids
}A three-tier-app module here composes a networking module, a compute module, and a database module into one higher-level unit an application team can call with a handful of top-level inputs — without needing to understand VPC subnet design or RDS parameter groups at all. This layering — general-purpose modules at the bottom, opinionated organization-specific modules built on top of them — is what lets a platform team encode real operational knowledge (security group defaults, backup policies, naming conventions) once, instead of relying on every application team to independently get it right.
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.
- Write a README in the module directory itself, even a short one — the variables and outputs describe what a module accepts, but not why it's structured the way it is, or what it deliberately doesn't support.
for_each on modules: instantiating many similar callers without repetition
The two-call example earlier in this post (web_dev and web_production) works fine for a small, fixed number of environments. For a genuinely variable or larger set of near-identical instances, for_each avoids writing one module block per instance by hand:
variable "environments" {
type = map(object({
instance_type = string
}))
default = {
dev = { instance_type = "t3.micro" }
staging = { instance_type = "t3.small" }
prod = { instance_type = "m5.large" }
}
}
module "web" {
for_each = var.environments
source = "./modules/web-server"
environment = each.key
instance_type = each.value.instance_type
ami_id = "ami-0c1a7f89451184c8b"
}One module block now creates one instance of the module per entry in var.environments, referenced individually as module.web["dev"], module.web["staging"], and so on. Adding a fourth environment means adding one line to the environments variable, not writing an entire new module block — a genuinely different level of maintainability once the number of near-identical instances grows past a small, fixed handful.
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.
- Modules compose — a higher-level module calling several lower-level ones is how organizations encode real operational knowledge once, instead of leaving every team to rediscover it.
- 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.
