Guides/TerraformTerraform/Terraform Modules: Reusable Infrastructure

Terraform Modules: Reusable Infrastructure

How Terraform modules turn repeated resource blocks into reusable, parameterized building blocks: writing, calling, sourcing, and versioning them the right way.


Once you have written more than one Terraform config, you notice the same shapes repeating: a bucket with the same tags and encryption, a VPC with the same subnet math, a service with the same security group and IAM plumbing. Copy-pasting that between projects is how infrastructure drifts - one copy gets a fix and the others do not. Modules are the fix. A module is a reusable, parameterized group of resources you define once and call many times, so the shape stays consistent everywhere it is used. This guide covers what a module actually is, how to write and call one, where the code can live, how to pin versions, and the one mistake almost everyone makes early: reaching for abstraction before there is anything to abstract.

What a module is (and why you want one)

A module is nothing more exotic than a directory of .tf files. That is the whole definition. Every Terraform configuration is already a module - the one you run terraform apply in is called the root module. A module becomes reusable when you give it inputs (variables you pass in) and outputs (values it hands back), so the same code produces different infrastructure depending on what you feed it.

The reason to use them comes down to three things:

  • DRY - write the bucket-with-encryption-and-tags pattern once, not once per environment. A fix or a new best practice lands in one place and every caller picks it up.
  • Consistency - every S3 bucket created through your module gets the same encryption, the same tag scheme, the same logging config. You stop relying on people remembering to set them.
  • Encapsulation - the caller says "give me a bucket named X, in environment Y." They do not need to know about the bucket policy, the public-access block, or the versioning resource inside. The module hides the wiring behind a small, clear interface.

If you have read the variables and outputs guide, you already know the two halves of a module's interface. Modules are just variables and outputs applied to a whole group of resources instead of a single config.

Root module vs child modules

There are two roles a module can play, and it is the same code either way - the role is about position, not type.

  • The root module is the directory where you run terraform init and terraform apply. It is the entry point. It holds your providers, your backend config, and the module blocks that call everything else.
  • A child module is any module called by another module. It never runs on its own; it is invoked from a module block. Child modules can call their own child modules, but keep that nesting shallow - two or three levels deep and you are already fighting to trace where a value came from.

The mental model: the root module is the composition, the wiring diagram. Child modules are the components. The root passes inputs down and reads outputs back up; the children know nothing about who called them or why.

Writing a module

A module's interface is its variables (in) and outputs (out); everything else is the private implementation. Here is a small but realistic one - a reusable S3 bucket that is always tagged, always versioned, and always has public access blocked. Put these in a directory, say modules/s3-bucket/.

modules/s3-bucket/variables.tf - the inputs the caller must or may provide:

variable "bucket_name" {
  description = "Globally unique name for the bucket"
  type        = string
}

variable "environment" {
  description = "Environment this bucket belongs to (dev, staging, prod)"
  type        = string
}

variable "versioning_enabled" {
  description = "Whether to keep old object versions"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Extra tags to merge onto the bucket"
  type        = map(string)
  default     = {}
}

modules/s3-bucket/main.tf - the resources, the part the caller never has to think about:

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name

  tags = merge(
    {
      Environment = var.environment
      ManagedBy   = "terraform"
    },
    var.tags,
  )
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id
  versioning_configuration {
    status = var.versioning_enabled ? "Enabled" : "Suspended"
  }
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket                  = aws_s3_bucket.this.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

modules/s3-bucket/outputs.tf - the values the caller gets back:

output "bucket_id" {
  description = "The name/ID of the bucket"
  value       = aws_s3_bucket.this.id
}

output "bucket_arn" {
  description = "The ARN, for use in IAM policies"
  value       = aws_s3_bucket.this.arn
}

Two conventions worth adopting from the start. Name the primary resource this (as above) when a module manages one main thing - it reads cleanly and avoids awkward names like aws_s3_bucket.bucket. And define a provider only in the root module, not inside child modules; children inherit the provider from their caller. Declaring providers inside a reusable module makes it painful to use more than once.

Calling a module

You call a module with a module block. You give it a name, tell it where to find the code with source, and pass your inputs as arguments. This is the root module - the main.tf you actually apply:

module "app_uploads" {
  source = "./modules/s3-bucket"

  bucket_name = "acme-app-uploads-prod"
  environment = "prod"
  tags = {
    Team = "platform"
  }
}

module "app_logs" {
  source = "./modules/s3-bucket"

  bucket_name        = "acme-app-logs-prod"
  environment        = "prod"
  versioning_enabled = false
}

Two calls, two buckets, one definition - both get the encryption-of-tags, the public-access block, and the version config for free, and each only had to state what makes it different. That is the payoff in a single screen.

To use a module's outputs, reference them as module.<name>.<output>. Say you want an IAM policy that grants access to the uploads bucket:

data "aws_iam_policy_document" "uploads_access" {
  statement {
    actions   = ["s3:GetObject", "s3:PutObject"]
    resources = ["${module.app_uploads.bucket_arn}/*"]
  }
}

output "uploads_bucket" {
  value = module.app_uploads.bucket_id
}

The caller reaches into module.app_uploads.bucket_arn - one of the outputs the module chose to expose. Anything the module did not declare as an output is invisible from here, which is exactly the encapsulation you want.

One workflow note: whenever you add, remove, or change a module's source, run terraform init again. Terraform installs and records modules at init time, so a plan before re-initializing will complain that the module is not installed.

Where module code lives: sources

The source argument decides where Terraform fetches the module from. There are three you will use in practice.

Local paths - a directory in the same repository. Always start with a relative path (./ or ../) so Terraform reads it as a local module rather than a registry lookup.

module "app_uploads" {
  source = "./modules/s3-bucket"
  # ...
}

Local modules are the right first step: they let you factor out repetition inside one repo with zero publishing overhead.

Terraform Registry - published modules, either the public registry or a private one. The address is namespace/name/provider, and these support versioning.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1"
  # ...
}

The public registry has mature, battle-tested modules (the terraform-aws-modules collection especially) for VPCs, EKS clusters, RDS, and more. Reaching for one of these is often smarter than writing your own VPC module from scratch.

Git repositories - a module in any git repo, which is how most teams share modules across projects before they set up a private registry.

module "network" {
  source = "git::https://github.com/acme/tf-modules.git//network?ref=v1.4.0"
  # ...
}

The //network selects a subdirectory within the repo, and ?ref=v1.4.0 pins to a tag, branch, or commit. Pinning to a ref here is the git equivalent of setting version on a registry module - and just as important.

Versioning: pin everything you can

For any module you do not fully control - registry modules and git modules - pin the version. The version argument (registry) or ?ref= (git) locks you to a specific release. Skip it and Terraform is free to pull whatever is newest, which means someone else's change can rewrite your plan on a day you were not expecting it.

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.8"   # >= 5.8.0 and < 5.9.0
}

The version argument takes constraint operators:

  • = 5.8.1 (or just 5.8.1) - exactly this version. Most predictable.
  • ~> 5.8 - allow patch and minor within 5.x from 5.8 up, but not 6.0. The common choice: bug fixes flow in, breaking major bumps do not.
  • ~> 5.8.0 - allow patch only (5.8.x), no new minors. Tighter.
  • >= 5.0, < 6.0 - an explicit range.

The reason is not paranoia, it is repeatability. Terraform's promise is that the same config produces the same infrastructure. An unpinned module breaks that promise: init today and init next month can resolve to different code and produce a different plan for input you never touched. Pin the version, upgrade deliberately, read the changelog, and let your version control show exactly when the bump happened. Your local modules do not need pinning because they live in the same repo and version alongside your code - the pinning discipline is for anything you pull in from outside.

Do not over-abstract too early

The most common module mistake is not writing too few - it is writing too many, too soon. A module wrapping a single resource with no added inputs, defaults, or logic gives you nothing but a layer of indirection to click through. Worse is the "mega-module" with thirty variables and a dozen feature flags trying to cover every possible case; it becomes harder to read, and change, than the raw resources it replaced.

The honest rule: wait for the repetition. Write the resources inline the first time. When you find yourself copy-pasting the same group of resources a second and third time - and you can see the small set of things that actually vary between the copies - that is the moment to extract a module, and by then the interface designs itself because you have real examples. A module built from three real use cases has the right variables. A module built from imagining future ones has variables nobody needs and is missing the ones they do.

Two smells to watch for: a module used exactly once (it is just a directory, inline it), and a module with a variable for every field of its resources (you have not abstracted anything, you have added a passthrough). Good modules hide decisions and expose a small, meaningful interface. If yours does neither, it is earning its keep as a folder, not a module.

The shape of it

A module is a directory of resources with a clear interface: variables in, outputs out, implementation hidden between. You write one to stop repeating yourself, to keep every instance consistent, and to hide wiring behind a small surface. The root module composes; child modules are the components it calls with module blocks, passing inputs down and reading module.<name>.<output> back up. Source them locally while you factor out repetition in one repo, from the registry or git when you share across projects - and pin the version on anything you do not control, because reproducibility is the whole point. Above all, let the repetition come first. The best modules are extracted from real, duplicated code, not invented ahead of a need that may never arrive.