Terraform Modules In Depth

A complete reusable module with variable validation, locals, and outputs, plus versioning modules via Git refs and registry constraints.

Beyond the basics

Variables, modules & workspaces introduced the core idea of a module: a self-contained, reusable package of configuration, parameterized like a function. This page builds a genuinely complete module — with input validation, sensible defaults, computed locals, and a real output surface — and covers how modules are versioned so teams can upgrade them deliberately rather than being at the mercy of whatever the source directory currently contains.

A complete, reusable module

Here's a module that provisions an S3 bucket configured for static website hosting — small enough to read in full, but with every piece a genuinely reusable module needs.

Hcl
# modules/static-site/variables.tf
variable "bucket_name" {
  description = "Globally unique S3 bucket name for this site"
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9.-]{3,63}$", var.bucket_name))
    error_message = "bucket_name must be 3-63 characters: lowercase letters, digits, dots, and hyphens only."
  }
}

variable "environment" {
  description = "Deployment environment name, used in tags"
  type        = string
  default     = "production"
}

variable "index_document" {
  description = "Filename served for the site's root and directory paths"
  type        = string
  default     = "index.html"
}

variable "enable_versioning" {
  description = "Whether to keep prior versions of every object in the bucket"
  type        = bool
  default     = true
}
Hcl
# modules/static-site/main.tf
locals {
  # A single place computed values derive from — kept out of variables.tf
  # because callers don't set these directly, they're derived.
  common_tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Module      = "static-site"
  }
}

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
  tags   = local.common_tags
}

resource "aws_s3_bucket_website_configuration" "this" {
  bucket = aws_s3_bucket.this.id

  index_document {
    suffix = var.index_document
  }
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = var.enable_versioning ? "Enabled" : "Suspended"
  }
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket = aws_s3_bucket.this.id

  # A static site bucket needs to be publicly readable; every other
  # public-access setting stays blocked by default.
  block_public_acls       = true
  block_public_policy     = false
  ignore_public_acls      = true
  restrict_public_buckets = false
}
Hcl
# modules/static-site/outputs.tf
output "bucket_id" {
  description = "The bucket's name/ID, for referencing elsewhere"
  value       = aws_s3_bucket.this.id
}

output "website_endpoint" {
  description = "The public HTTP endpoint serving the site"
  value       = aws_s3_bucket_website_configuration.this.website_endpoint
}

A few pieces worth calling out:

  • validation blocks turn "silently create a bucket with an invalid name and let AWS reject it" into an immediate, readable error at terraform plan time — a much faster feedback loop, and self-documenting about what a valid input actually looks like.
  • locals hold values derived inside the module (like the shared tag set) as distinct from variables, which are the module's actual inputs supplied by the caller. Mixing the two up — accepting a full tag map as a variable when the module really wants to compute it — makes a module's real interface harder to see at a glance.
  • Sensible defaults (environment, index_document, enable_versioning all have one) mean a caller only has to think about bucket_name for the common case, while still being able to override anything.
  • Outputs are the module's public return value. Nothing outside the module can reference aws_s3_bucket.this directly — only what's explicitly exposed through output blocks, exactly like a function's return value versus its private local variables.

Calling the module

Hcl
# root main.tf
module "marketing_site" {
  source      = "./modules/static-site"
  bucket_name = "myapp-marketing-site"
  environment = "production"
}

module "docs_site" {
  source            = "./modules/static-site"
  bucket_name       = "myapp-docs-site"
  environment       = "production"
  index_document    = "home.html"
  enable_versioning = false
}

output "marketing_site_url" {
  value = module.marketing_site.website_endpoint
}

Two calls to the same module, each producing an independent, fully-configured bucket — exactly the payoff of writing it as a module in the first place rather than duplicating the resource blocks with small variations each time.

Versioning modules

A module referenced by a plain local path (source = "./modules/static-site") always uses whatever is currently sitting in that directory — there's no version to speak of, which is fine within a single repository but risky the moment a module is meant to be shared across multiple, independently-deployed configurations or teams. Two ways to pin a module to a specific, deliberate version:

Git source with a ref

Hcl
module "static_site" {
  source = "git::https://github.com/myorg/terraform-modules.git//static-site?ref=v1.4.0"

  bucket_name = "myapp-marketing-site"
}

?ref=v1.4.0 checks out that exact tag — upgrading to a newer module version is then a deliberate, reviewable one-line change (ref=v1.4.0 -> ref=v1.5.0) rather than an unannounced behavior change appearing the next time someone runs terraform init.

The Terraform Registry, with a version constraint

Hcl
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.8"

  name = "myapp-vpc"
  cidr = "10.0.0.0/16"
}

version = "~> 5.8" means "any 5.8.x release, but not 5.9 or above" — the ~> ("pessimistic constraint") operator allows patch/minor upgrades within a major version while refusing to silently jump to a version that might include breaking changes. This is the same reasoning behind similar constraint operators in most other package ecosystems (Composer, npm), applied to infrastructure modules.

Source style Example Versioning
Local path ./modules/static-site None — always current directory contents
Git with ref git::...?ref=v1.4.0 Manual — pin to a tag/branch/commit
Terraform Registry terraform-aws-modules/vpc/aws Version constraints (version = "~> 5.8")

Nested modules (composition)

Modules can call other modules, letting a higher-level module compose several lower-level ones — a web-app module might internally call the static-site module above alongside a CDN module and a DNS module, exposing one clean interface to its own callers while hiding that composition entirely. Terraform has no fixed limit on nesting depth, but in practice going more than two or three levels deep tends to make it hard to trace which inner module a given resource actually came from — flatter compositions are usually easier to reason about than deeply nested ones.

Common mistakes

  • Referencing a shared module by local path across independently-deployed configurations that live in different repositories — there's no way to pin a version, so every caller is silently exposed to whatever the module's maintainer changes next.
  • Accepting far too many optional inputs with no validation, so a typo'd string silently reaches the cloud provider's API instead of failing fast with a clear message at plan time.
  • Putting resources that don't conceptually belong together into one "kitchen sink" module — a module should have one clear job, the same discipline you'd apply to a function or a class.
  • Forgetting that outputs are a module's only public surface — reaching into module.static_site.aws_s3_bucket.this.arn isn't valid Terraform; if a caller needs a value, the module must explicitly expose it via an output block.
  • Bumping a ~> version constraint without reading the module's changelog first — a minor version bump in a well-behaved module shouldn't break callers, but "shouldn't" isn't "can't."