Composable Terraform Modules

TerraformInfrastructureDevOpsModules
Share on LinkedIn Share on X Share on Reddit Share on HN Share on Bluesky

Four teams copied the same 200-line VPC configuration into their repos. Within six months they diverged: Team A added VPC flow logs; Team B used wrong CIDR sizing; Team C shipped a security group bug the others avoided; Team D never upgraded provider constraints. Module extraction took one week; alignment took one terraform plan cycle. One module "vpc" call replaced four forks of truth.

Terraform modules are reusable infrastructure packages. Good modules have tight interfaces, tested defaults, semantic versioning, and compose cleanly. Bad modules are 400-line copy-paste blocks with forty undocumented variables and a README that says "see main.tf."

Module anatomy and repository layout

modules/vpc/
  main.tf        # Resources
  variables.tf   # Inputs with descriptions, types, validation
  outputs.tf     # Contract surface for consumers
  versions.tf    # terraform and provider version constraints
  README.md      # Examples, input/output tables (terraform-docs)
  examples/complete/
  tests/vpc.tftest.hcl

Single responsibility: a VPC module creates VPC, subnets, route tables, NAT, flow logs. It does not also deploy ECS clusters. An ecs-service module consumes VPC outputs.

Interface design: minimum viable surface

Expose only what callers must customize:

variable "name" {
  description = "Prefix for resource names"
  type        = string
}

variable "cidr_block" {
  description = "VPC CIDR"
  type        = string
  default     = "10.0.0.0/16"
}

variable "az_count" {
  description = "Number of AZs (2-4)"
  type        = number
  default     = 2
  validation {
    condition     = var.az_count >= 2 && var.az_count <= 4
    error_message = "Use 2-4 AZs for HA; standard prod uses 3."
  }
}

Every variable gets description, type, sensible default, and validation where constraints exist. Catch misconfig at plan time, not when NAT gateway billing surprises finance.

Outputs export what downstream modules need — nothing more:

output "vpc_id" { value = aws_vpc.this.id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }

Internal resources stay private. Removing an output is a breaking change requiring major version bump.

Composition at the root module

Root modules wire environment-specific values and orchestrate child modules:

module "vpc" {
  source     = "git::https://github.com/org/terraform-modules.git//vpc?ref=v2.1.0"
  name       = "production"
  cidr_block = "10.1.0.0/16"
  az_count   = 3
}

module "api_service" {
  source      = "git::https://github.com/org/terraform-modules.git//ecs-service?ref=v1.4.0"
  name        = "api"
  vpc_id      = module.vpc.vpc_id
  subnet_ids  = module.vpc.private_subnet_ids
  cluster_arn = module.ecs_cluster.arn
}

Data flows through outputs → inputs. No remote state reads between sibling modules in the same root — keeps plans atomic and dependency graph explicit in one terraform graph.

Versioning, pinning, and upgrade cadence

Tag modules with semver. Consumers pin:

source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v2.1.0"

Never ref=main in production. Breaking changes bump major version; document in CHANGELOG.md with migration steps.

Version bump When Example
Major Removed/changed output private_subnet_ids split into two outputs
Minor New optional variable enable_ipv6 = false default
Patch Bug fix Fix NAT route association

Establish Renovate/Dependabot for module version bumps in consumer repos. Weekly PR with plan diff attached. Major version bumps require platform team office hours.

Wrapping upstream modules

The public Terraform AWS VPC module is excellent — wrap it to enforce org defaults:

module "upstream_vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.1"
  enable_flow_log    = true  # org-mandated
  enable_nat_gateway = true
}

Your wrapper adds tagging standards, naming conventions, and policy-compliant defaults without forking upstream. Upgrade upstream minor versions in wrapper; consumers pin wrapper version.

for_each vs count: stable resource addressing

count shifts indices when list order changes — destroys wrong resources:

resource "aws_subnet" "private" {
  for_each = { for s in var.subnets : s.name => s }
  cidr_block = each.value.cidr
}

Document in module README — consumers learn after painful RDS recreation if you do not warn explicitly.

Provider alias patterns for multi-region

Multi-region modules pass provider aliases explicitly:

module "replica_bucket" {
  source = "./s3-replica"
  providers = { aws.replica = aws.west }
}

Module README lists required provider configurations — implicit defaults cause cryptic plan errors in consumer roots.

Testing modules before release

Terraform 1.6+ native tests in tests/*.tftest.hcl:

run "subnet_count_matches_az" {
  command = plan
  variables { name = "test", az_count = 3 }
  assert {
    condition     = length(aws_subnet.private) == 3
    error_message = "Expected one private subnet per AZ"
  }
}

Run terraform test in module repo CI before tagging release. examples/complete/ runs terraform init -backend=false, validate, and test on every PR.

Terragrunt for DRY environment config

Terragrunt wraps Terraform for DRY backend config and provider generation:

terraform {
  source = "git::...//vpc?ref=v2.1.0"
}
inputs = {
  name       = "staging"
  cidr_block = "10.2.0.0/16"
}

Parent terragrunt.hcl generates backend.hcl and provider.tf — environments differ only in inputs.

Anti-patterns that rot module libraries

Anti-pattern Why it hurts Fix
Kitchen-sink module VPC + RDS + ECS in one module Split by domain
Leaky map(any) passthrough Consumers bypass defaults Explicit variables
Hidden IAM role names Collisions across environments Prefix with var.name
Unpinned providers in module Consumer resolution breaks Pin in versions.tf

terraform-docs and README discipline

terraform-docs generates input/output tables from variable blocks — README stays synchronized with code. Module PRs without updated examples/complete diff rejected in CODEOWNERS review — documentation drift is the leading cause of module misconfiguration.

Governance: CODEOWNERS and RFC for breaking changes

Platform team owns modules/vpc/CODEOWNERS. Major version requires RFC: migration guide, terraform state mv commands, deprecation window. Release candidate tags tested against three consumer roots before GA. Private Terraform Registry resolves modules faster than git:: clone every CI plan.

When NOT to module

The test: will another team instantiate this exact pattern twice? If no, inline until pattern stabilizes.

Synthesis

Module when pattern repeats; minimal validated interface; semver pin; compose via outputs; test before tag; wrap upstream for org defaults. Modules are APIs for infrastructure — design them with the same rigor as public library contracts.

Module composition patterns in practice

Three composition patterns cover most org layouts:

Pattern Structure Best for
Layered stack network → data → compute → app Platform team owns lower layers
Service module One module per deployable service Product teams own full stack slice
Wrapper + upstream Thin org wrapper around registry module Enforcing defaults without fork

Layered stacks apply in dependency order — network state must apply before compute state reads subnet IDs.

Consumer contract tests

Downstream roots snapshot expected module.vpc outputs in tests/fixtures/expected_outputs.json — CI fails when module minor version removes output field platform apps depend on.

Changelog and semver contract

Every module tag requires CHANGELOG.md entry. Consumers subscribe to GitHub Releases RSS. Deprecation window: six months README banner before removing module input.

Examples directory enforced in CI

examples/complete/ runs terraform init -backend=false, validate, and test on every PR. Broken examples block merge before consumers copy broken patterns.

Private registry over git sources

Terraform Cloud private registry resolves modules faster than git:: clone every CI plan. Publish on tag push; consumers pin version = "2.1.0" not floating git ref. Release notes changelog is contract.

Variable validation UX

Validation error_message must say how to fix: "az_count must be 2-4 for HA; use 3 for standard prod" not "Invalid value." Plan-time errors are UX for other teams consuming your module.

Module upgrade communication

Publish migration notes in platform newsletter when releasing major module versions — not only GitHub Releases.

Resources

Frequently asked questions

When should I create a Terraform module vs inline resources?

Create a module when you'll instantiate the same pattern more than twice — a VPC setup, an ECS service, a standard S3 bucket with encryption and logging. Keep one-off resources inline. Over-modularizing (a module for a single security group rule) adds indirection without reuse benefit. The test: would another team or environment use this exact combination of resources?

How do I version Terraform modules?

Tag module repositories with semantic versions (v1.2.0). Consumers pin to a version: source = 'git::https://github.com/org/terraform-modules/vpc?ref=v1.2.0'. Never point production at main/master — a breaking change in the module repo shouldn't break production on the next plan. Use the Terraform Registry for public modules; private registries (Terraform Cloud, Artifactory) for internal modules.

What belongs in a module's interface?

Inputs: the minimum variables needed to customize behavior (name, environment, CIDR range, instance type). Outputs: everything downstream modules or root modules need (VPC ID, subnet IDs, security group IDs). Hide internal implementation details — consumers shouldn't need to know how many subnets the VPC module creates, just the subnet IDs they can pass to an EC2 module.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →