Writing reusable Terraform modules that don't fight you
Design modules with clear inputs and outputs, sane defaults, and versioning so teams reuse them without forking.
You have written the same VPC three times. Same private subnets, same flow logs, same NAT layout — and three chances to typo a CIDR or forget an endpoint. A Terraform module is a folder of .tf files with declared variables and outputs, called from a root module. Done well, it is a contract: water in, drain out, and nobody rebuilds the pump in every kitchen.
This note covers module layout, how to expose only what callers need, versioning with git tags, and the testing mindset that keeps modules from becoming junk drawers. Ground yourself in Terraform fundamentals first — modules assume you already understand state and providers.
Extract when the third copy appears. Publish semver tags. Consumers pin tags, not branches.
Design the module contract
Every input should have a type, a description, and a validation block where constraints matter — CIDR format, allowed instance sizes, enum environments. Outputs expose ids callers need (vpc_id, private_subnet_ids) and hide internal implementation details. If consumers reach into your module with terraform state pull hacks, the interface is wrong.
Document the module README with an example module block, input table, and output table — the same contract you'd publish to the public registry. Semantic versioning matters: patch for bugfixes, minor for backward-compatible inputs, major when you rename outputs or change defaults that recreate resources. Consumers should read CHANGELOG before bumping ?ref=v2.0.0.
variable "name" {type = stringdescription = "Environment prefix for resource names"}variable "cidr" {type = stringdescription = "VPC CIDR block"validation {condition = can(cidrhost(var.cidr, 0))error_message = "cidr must be a valid IPv4 CIDR."}}
Call the module from a root
Pin source to a git ref — tag or commit sha — never main. Registry modules (terraform-aws-modules/vpc/aws) are fine when you trust the publisher; internal modules should live in a dedicated repo with CODEOWNERS on the module path.
Test modules with terraform test (HCL fixtures) or a small examples/complete root that runs in CI on every tag. A module that plans clean but apply fails on edge-case CIDRs costs every downstream team an afternoon. Keep provider version constraints in versions.tf inside the module so callers inherit compatible provider pins.
module "vpc" {source = "git::https://git.example.com/infra/vpc.git?ref=v1.4.2"name = "prod"cidr = "10.20.0.0/16"}output "vpc_id" {value = module.vpc.vpc_id}
terraform init -upgrade=falseDownloading git::https://.../vpc.git?ref=v1.4.2terraform plan -out=plan.binmodule.vpc.aws_vpc.this: refreshPlan: 0 to add, 0 to change, 0 to destroy.Where this goes next
Version modules, scan them with Checkov before publish, and store state remotely so every consumer plans against the same backend pattern. For multi-environment DRY without losing clarity, combine modules with separate state keys per stack. The Terraform course walks module testing, registry publishing, and composition patterns.
Nested modules (modules/vpc calling modules/subnet) are fine when each layer has a clear contract. Avoid modules that call modules that call modules without documentation — plan output becomes unreadable and reviewers cannot see blast radius.