NovuSpark
All articles
TerraformJanuary 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.

modules/web-serverone implementationmodule "web_dev"t3.micromodule "web_production"m5.largesource = "./modules/web-server"
Fig. 1 — two callers, one module implementation — a bug fix inside it reaches both automatically

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.

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.