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·Updated ·5 min readIntermediate·By SecOpsLog · documentation-verified

The third copy of the VPC stack is the moment to write a module, and the reason is not typing. Three copies of the same forty lines are three places for a flow-log setting to drift, three ways to spell an environment name, and three reviewers who each believe the other two are the canonical one. A module is a folder of .tf files with declared inputs and outputs, and what makes it worth having is the part that is not code: the contract it states, the tests that hold it to that contract, and the version number that tells a caller whether an upgrade can be taken blind.

Inputs that refuse bad values, outputs that hide the rest

modules/vpc/variables.tf
variable "name" {
type = string
description = "Environment prefix for every resource name (prod, staging, dev-alice)."
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,23}$", var.name))
error_message = "name must be 2-24 characters: lowercase letters, digits and hyphens, starting with a letter."
}
}
variable "cidr" {
type = string
description = "VPC CIDR block; /16 to /20 so subnets can be carved per AZ."
validation {
condition = can(cidrhost(var.cidr, 0)) && tonumber(split("/", var.cidr)[1]) >= 16 && tonumber(split("/", var.cidr)[1]) <= 20
error_message = "cidr must be a valid IPv4 CIDR between /16 and /20."
}
}
variable "flow_logs" {
type = bool
description = "Send VPC flow logs to the account log bucket. Off only for throwaway sandboxes."
default = true
}
modules/vpc/outputs.tf and versions.tf
output "vpc_id" {
value = aws_vpc.this.id
description = "VPC id for security groups and peering."
}
output "private_subnet_ids" {
value = [for s in aws_subnet.private : s.id]
description = "Private subnets, one per AZ, in AZ order."
}
# not exported: route tables, NAT gateway ids, the flow log group. Callers that need them
# are asking the module to become their implementation, and the interface should say no.
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0" # the module states what it was written against; the root pins exactly
}
}
}

A validation block turns a wrong value into a plan-time error with a sentence a human wrote, instead of an apply-time failure from the provider with a sentence nobody wrote. Outputs are the other half of the contract: expose the ids a caller needs to connect things, and nothing that describes how the module built them. The required_providers block inside the module declares compatibility; the exact provider version is pinned once, in the root, and in the lock file it commits.

A test that runs on every tag

terraform test runs .tftest.hcl files: each run block plans or applies the module with given variables and checks assertions against the result. A plan-mode test needs no credentials with real effect, runs in seconds, and catches the two failures that cost downstream teams an afternoon: an input the validation should have rejected, and a default that quietly changed. The test lives in the module repository and runs in CI before a tag is created; a tag means something only because the test ran first.

modules/vpc/tests/contract.tftest.hcl
variables {
name = "test"
cidr = "10.42.0.0/16"
}
run "flow_logs_are_on_by_default" {
command = plan
assert {
condition = length(aws_flow_log.this) == 1
error_message = "flow logs must be enabled unless flow_logs = false is passed explicitly"
}
}
run "one_private_subnet_per_az" {
command = plan
assert {
condition = length(aws_subnet.private) == length(data.aws_availability_zones.available.names)
error_message = "private subnets must cover every AZ"
}
}
run "rejects_a_public_sized_cidr" {
command = plan
variables {
cidr = "10.0.0.0/8"
}
expect_failures = [var.cidr]
}

command = apply is the other mode: the run creates the resources for real and destroys them when the file finishes, which is the only way to test that a NAT route actually routes or that a bucket policy actually denies. It costs money and minutes, so it belongs in a sandbox account on a nightly schedule or a release branch rather than on every commit, and it needs a cleanup job for the day a run is interrupted and leaves a VPC behind. Plan-mode tests are the gate; apply-mode tests are the evidence a major version is safe to publish.

bash — the module CI job
terraform init -backend=false && terraform validate && terraform test
tests/contract.tftest.hcl... in progress
run "flow_logs_are_on_by_default"... pass
run "one_private_subnet_per_az"... pass
run "rejects_a_public_sized_cidr"... pass
Success! 3 passed, 0 failed.
git tag -a v1.5.0 -m "vpc: flow_logs input (default on), no caller changes required"

What the version number promises

Semantic versioning for a module

BumpWhenWhat a caller must do
patch (v1.4.3)a bug fix that produces no plan change for existing callersnothing; take it
minor (v1.5.0)a new optional input or output; a new resource with a safe defaultread the changelog; take it in a normal window
major (v2.0.0)a renamed output, a removed input, a default that recreates a resource, a provider majorplan carefully; expect changes, possibly replacements

The rule that keeps the table honest is that a plan run by an existing caller decides the bump, not the size of the diff: a one-line default change that recreates every NAT gateway is a major. Callers pin a tag (or a commit SHA) and bump it through a merge request; a source that points at main receives every major the day it lands.

live/prod/network/main.tf
module "vpc" {
source = "git::https://git.acme.dev/modules/vpc.git?ref=v1.5.0" # a tag, bumped by a reviewed change
name = "prod"
cidr = "10.20.0.0/16"
}
output "vpc_id" {
value = module.vpc.vpc_id
}
Forty optional inputs is three modules wearing one name
When every caller passes a dozen enable_this and override_that flags, the module has stopped stating a contract and started forwarding decisions. Split it along the seams the flags reveal (a VPC module, a NAT strategy module, an endpoints module), keep each one’s interface short enough to read in one screen, and compose them in the root. Terragrunt or a wrapper cannot fix this; it hides it.
Module or root?
Extract into a module
The same stack exists three or more times
Inputs and outputs are nameable in a sentence
One team owns it and its tests
Consumers pin tags and read a changelog
Keep in the root
A one-off resource with no second caller
Glue between two modules
An interface still changing weekly
Environment-specific one-liners

A module with a contract and a tag is what makes a directory per environment cheap and what Terragrunt has to work with; the policy scan that runs on the module repository before tagging is how a secure default ships to every caller instead of being suppressed in each one.

Related posts

Quick reference