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
# 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.
# 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.
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.
# modules/ecs-service/versions.tf
terraform {
required_version = ">= 1.8.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 6.0"
}
}
}
Discussion