NovuSpark
All articles

January 16, 2026 · NovuSpark Team

Terraform Modules: Building Reusable Infrastructure

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.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.