BlogIaC

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.

Oct 15, 2025·4 min readIntermediate·By the SecOpsLog team · command-tested

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.

From root module to shared library

Extract when the third copy appears. Publish semver tags. Consumers pin tags, not branches.

1IdentifyduplicationVPC, EKS, RDS patterns2Createmodules/vpc/variables.tf + outputs.tf3Define contractrequired vs optional inputs4Tag v1.0.0git tag + CHANGELOG5Root calls modulesource + version pin6terraform test /planfixture configs7Bump minorbackward-compatible only

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.

modules/vpc/variables.tf
variable "name" {
type = string
description = "Environment prefix for resource names"
}
variable "cidr" {
type = string
description = "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.

main.tf
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
}
bash — plan against a pinned modulelive
terraform init -upgrade=false
Downloading git::https://.../vpc.git?ref=v1.4.2
terraform plan -out=plan.bin
module.vpc.aws_vpc.this: refresh
Plan: 0 to add, 0 to change, 0 to destroy.
Modules are not a junk drawer
If every environment passes twenty `special_case_*` variables, split the module or compose smaller ones. A module with forty optional flags is a monolith wearing a trench coat. Fix the API before wrapping it in Terragrunt.
Good module boundaries
Extract into a module
Same stack repeated 3+ times
Clear inputs and outputs
Owned by one platform team
Semver-tagged releases
Keep in root module
One-off snowflake resources
Glue between two modules
Experimentation before API stabilizes
Environment-specific one-liners

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.

Go deeper in a courseTerraformModules, remote state, workspaces, and CI apply pipelines.View course

Related posts