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.
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
variable "name" {type = stringdescription = "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 = stringdescription = "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]) <= 20error_message = "cidr must be a valid IPv4 CIDR between /16 and /20."}}variable "flow_logs" {type = booldescription = "Send VPC flow logs to the account log bucket. Off only for throwaway sandboxes."default = true}
output "vpc_id" {value = aws_vpc.this.iddescription = "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.
variables {name = "test"cidr = "10.42.0.0/16"}run "flow_logs_are_on_by_default" {command = planassert {condition = length(aws_flow_log.this) == 1error_message = "flow logs must be enabled unless flow_logs = false is passed explicitly"}}run "one_private_subnet_per_az" {command = planassert {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 = planvariables {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.
terraform init -backend=false && terraform validate && terraform testtests/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"... passSuccess! 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
| Bump | When | What a caller must do |
|---|---|---|
patch (v1.4.3) | a bug fix that produces no plan change for existing callers | nothing; take it |
minor (v1.5.0) | a new optional input or output; a new resource with a safe default | read 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 major | plan 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.
module "vpc" {source = "git::https://git.acme.dev/modules/vpc.git?ref=v1.5.0" # a tag, bumped by a reviewed changename = "prod"cidr = "10.20.0.0/16"}output "vpc_id" {value = module.vpc.vpc_id}
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.