DevOps · Terraform

Terraform Modules — A Field Guide

Designing Terraform modules that stay useful: honest input variables, outputs that don't leak implementation details, versioning with git tags, and why a module with 40 optional variables is worse than no module at all.

John Kihiu12 min read

Most bad Terraform modules are bad for the same reason: someone tried to anticipate every future use case instead of describing the one use case they had. A module isn't a virtue on its own — it's a trade. You give up some flexibility for less duplication and a smaller blast radius when you change something. The moment a module has more surface area than the resources it wraps, you've made the trade backwards.

Designing inputs and outputs

The variables block is the module's contract, and most leaky abstractions start there. A common mistake is exposing a variable because the underlying resource has an argument, not because a caller actually needs to set it. If every consumer of your module passes the same value for encryption_algorithm, that's not a variable — that's a default you haven't written yet. Good module inputs map to decisions the caller actually makes: environment, size, whether a feature is on. Everything else should have a sensible default baked in, or not be exposed at all.

Outputs have the same discipline problem in the other direction. It's tempting to output every attribute a resource exposes "in case someone needs it later." Don't. An output is part of your public API — once another module or root config depends on it, you can't quietly change its shape. Output the things composition actually requires (an ID, an ARN, a connection endpoint), not the entire resource as a convenience.

HCL
// modules/vpc/variables.tf
variable "name" {
  description = "Name prefix for all resources in this VPC"
  type        = string
}

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

variable "azs" {
  description = "Availability zones to spread subnets across"
  type        = list(string)
}

variable "enable_nat_gateway" {
  description = "Provision a NAT gateway for private subnet egress"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Tags applied to every resource this module creates"
  type        = map(string)
  default     = {}
}

// modules/vpc/outputs.tf
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.this.id
}

output "private_subnet_ids" {
  description = "IDs of the private subnets, in the same order as var.azs"
  value       = aws_subnet.private[*].id
}

output "public_subnet_ids" {
  description = "IDs of the public subnets, in the same order as var.azs"
  value       = aws_subnet.public[*].id
}

Notice what's missing: no vpc_arn output nobody asked for, no subnet_cidr_blocks output duplicating something callers can get from the subnet resource directly if they really need it, no boolean flag for every optional AWS feature the VPC resource supports. Four inputs that map to real decisions, three outputs that other modules actually consume.

Module composition and root wiring

Modules calling modules is normal and often the right shape, but it should mirror how your infrastructure actually decomposes, not how your file tree happens to be organized. A "networking" module that calls a "vpc" module that calls a "subnet" module is fine if each layer adds something — routing decisions, multi-region wiring, environment-specific defaults. It's a problem when each layer just re-passes the same six variables downward and adds nothing, because now a caller has to read three files to understand one decision.

The root module is where composition should actually happen for anything account- or environment-specific. Keep child modules generic about "a VPC" or "an RDS instance," and let the root module decide how many, which sizes, and how they wire together for this particular environment.

HCL
// root main.tf
module "vpc" {
  source = "git::https://github.com/acme/terraform-modules.git//vpc?ref=v1.4.0"

  name               = "prod-use1"
  cidr_block         = "10.20.0.0/16"
  azs                = ["us-east-1a", "us-east-1b", "us-east-1c"]
  enable_nat_gateway = true

  tags = {
    environment = "production"
    managed_by  = "terraform"
  }
}

module "app_cluster" {
  source = "git::https://github.com/acme/terraform-modules.git//ecs-service?ref=v2.1.0"

  vpc_id             = module.vpc.vpc_id
  private_subnet_ids = module.vpc.private_subnet_ids
  service_name       = "checkout-api"
  desired_count      = 3
}

The root module wires two independently versioned modules together using outputs as the glue. Neither module knows the other exists — ecs-service just needs a VPC ID and subnet IDs, which is exactly the composition boundary that keeps modules reusable across projects that don't share a network layout.

Versioning modules

Unversioned module sources are the single most common way teams get burned. If your source is a branch (ref=main) or, worse, unpinned entirely, then every terraform init can silently pull in someone else's change to the module and your plan diff has nothing to do with what you actually intended to change. Pin to a tag, always: source = "git::https://github.com/acme/terraform-modules.git//vpc?ref=v1.4.0". Tag the module repo with semver, bump the tag when you make a breaking change to variables or outputs, and let consumers upgrade on their own schedule by bumping the ref.

If you're publishing internally at any scale, a private registry (Terraform Cloud's, or a self-hosted one) is worth setting up over raw git sources — the source = "app.terraform.io/acme/vpc/aws" style reference gives you a real version constraint syntax (version = "~> 1.4") instead of exact git refs, plus a browsable index of what modules exist. For a single team with a handful of modules, git tags are fine and require zero extra infrastructure.

Treat module tags like a public API

A breaking change is anything that would fail an existing caller's plan without them editing their code: renaming a variable, removing an output, changing a variable's type. Bump the major version for those. Adding an optional variable with a default, or a new output, is safe as a minor or patch bump. Consumers pinning to a tag should be able to trust that distinction.

Avoid the kitchen-sink module

The failure mode nobody warns you about is the opposite of duplication: a module so generic it becomes harder to use than the raw resources it wraps. I've inherited modules with 40+ optional variables — half of them booleans toggling features nobody uses, a few of them nested objects with their own optional sub-fields — where understanding what a fifteen-line module block would actually provision took longer than writing the resources by hand. That's not reuse, it's indirection with extra steps.

The tell is usually the module's own defaults file: if you need a table to document which combinations of variables are valid together, or a variable exists purely to disable a resource block entirely (create_thing = false), the module is trying to be every possible shape of infrastructure instead of one shape, well. The fix is almost always to split it. A module for the common 80% case with three or four inputs, and a second, more explicit module (or just raw resources in the root) for the unusual cases, beats one mega-module trying to serve both.

If it needs a wiki page, it's too generic

A module whose variables require a separate document explaining which combinations are supported is a module that has stopped being an abstraction and started being a second, worse copy of the Terraform language. If onboarding a new caller means walking them through the variables file, consider whether two focused modules — or no module at all — would actually be less work.

Wrapping up

Good modules are boring: a handful of inputs that map to real decisions, outputs that other modules can build on without knowing your internals, and a version tag that means something when you bump it. The instinct to make a module handle every possible case is understandable — it feels like foresight — but in practice it just moves the complexity from "duplicated resource blocks" to "an API nobody can hold in their head." When in doubt, write the module for the case in front of you, version it, and let the next case earn its own variable.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.