Module Design & Versioning

Small, focused modules with pinned versions beat one giant do-everything module every time.

A good module is like a good function: it does one thing, takes clear inputs, returns clear outputs, and hides the messy middle. The temptation is to build a monolith that provisions your entire platform. Resist it.

What makes a module reusable

  • Narrow scope — "an S3 bucket with sane defaults", not "the whole data platform".
  • Typed variables with descriptions — the interface is the documentation.
  • Meaningful outputs — expose the IDs and ARNs callers actually need.
  • No hardcoded provider blocks — let the caller pass providers in.

Pin every version

Referencing a module by a floating main branch means a teammate's commit can change your infrastructure without warning. Pin a Git tag or a registry version, and bump it deliberately.

Example

Example · hcl
# Registry module — pinned to an exact version
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.13.0"

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

# Private Git module — pinned to a tag, never a branch
module "bucket" {
  source = "git::https://github.com/acme/tf-modules.git//s3?ref=v1.4.0"

  bucket_name = "acme-prod-assets"
  versioning  = true
}

When to use it

  • A platform team publishes a focused ecs-service module with five inputs rather than a monolithic infra module with 40 inputs that nobody can understand.
  • A module versioned at v2.0.0 introduces a breaking variable rename but bumps the major version so callers can choose when to migrate.
  • An engineer tests a module in isolation by writing a minimal root configuration in a test/ directory and applying it to a sandbox account before publishing.

More examples

Small focused module inputs

A module with 7 focused inputs is easy to call and test; contrast with a mega-module needing 40 variables to configure.

Example · hcl
# modules/ecs-service/variables.tf
variable "name"          { type = string }
variable "image"         { type = string }
variable "cpu"           { type = number; default = 256 }
variable "memory"        { type = number; default = 512 }
variable "cluster_arn"   { type = string }
variable "subnet_ids"    { type = list(string) }
variable "desired_count" { type = number; default = 1 }

Pin module version in caller

Pins the module to a Git tag so upgrades are explicit; the team bumps the ref in a PR to adopt new module versions.

Example · hcl
module "api_service" {
  source  = "git::https://github.com/my-org/tf-modules.git//modules/ecs-service?ref=v2.1.0"

  name        = "api"
  image       = "my-org/api:latest"
  cluster_arn = module.cluster.arn
  subnet_ids  = module.network.private_subnet_ids
}

versions.tf in every module

A versions.tf in every module declares the minimum Terraform version and provider range the module is tested against.

Example · hcl
# modules/ecs-service/versions.tf
terraform {
  required_version = ">= 1.8.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0, < 6.0"
    }
  }
}

Discussion

  • Be the first to comment on this lesson.