DevOps & observability / Tutorial

Terraform Modules Your Team Will Reuse: Interfaces, Versioning, and State Hygiene

Design Terraform modules with narrow interfaces, honest versioning, and clean state boundaries so other teams adopt them instead of copying and forking them.

You will build a Terraform module in this tutorial, but the artefact matters less than the decisions around it: what the module exposes, what it refuses to expose, how it versions, and where its state lives. Modules fail socially before they fail technically. A module that works perfectly and is never adopted, because its interface is a maze or its releases break consumers without warning, has not done its job. Reuse is the goal, and reuse is earned through interface discipline.

The running example is a module that provisions an application service: a container service, its task definition, a load balancer listener rule, and the IAM role the task runs as. You could substitute a queue, a database, or a Kubernetes namespace and the design rules would not change.

Start with the interface, not the resources

The most common module design error is to write the resources first and let the variables be whatever the resources happen to need. That produces an interface that is a transparent wrapper over the provider schema: forty variables, most of them pass-throughs, and a consumer who must understand the underlying resources anyway to use the module safely. At that point the module saves typing but not thinking, and teams route around it.

Design from the consumer’s side instead. Ask what decisions a consuming team should make, and expose exactly those. For the application service, a consumer should decide the service name, the container image, the CPU and memory sizing, the environment variables and secrets, and the desired count. A consumer should not decide IAM policy document structure, listener rule priorities, or log group naming conventions. Those are the module’s opinions, and opinions are the value a module adds.

variables.tf
variable "name" {
  type        = string
  description = "Service name. Used as the prefix for all resources the module creates."
 
  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,30}$", var.name))
    error_message = "name must be lowercase letters, digits, and hyphens, starting with a letter."
  }
}
 
variable "image" {
  type        = string
  description = "Container image reference, including tag or digest."
}
 
variable "cpu" {
  type        = number
  description = "CPU units for the task."
  default     = 512
}
 
variable "memory" {
  type        = number
  description = "Memory in MiB for the task."
  default     = 1024
}
 
variable "environment" {
  type        = map(string)
  description = "Plain environment variables for the container."
  default     = {}
}

Three habits in that short file do most of the interface work. Every variable has a description written for a stranger, because that description is the module’s documentation at the moment of use. Sizing variables carry defaults chosen to be safe for a typical service, so the minimal consumer configuration stays minimal. And the name variable validates its input at plan time, because a naming mistake caught by validation costs seconds, while the same mistake discovered as a provider error three resources deep costs an afternoon and some trust in the module.

Resist two temptations while you write this file. Do not add a variable for every attribute a resource supports, on the theory that someone might need it; each added variable is a permanent compatibility promise. And do not add boolean flags that switch the module between fundamentally different shapes. A flag that turns a public service into a private one changes networking, security groups, and load balancing all at once, and a module with three such flags is really eight modules wearing a trench coat. Split it.

Outputs are a contract too

Consumers depend on outputs the way they depend on variables, and breaking an output breaks them just as surely. Export identifiers and attributes other resources genuinely need: the service name, the task role ARN, the log group name. Do not export everything the resources produce, and never export a whole resource object as a convenience; that makes the provider’s entire attribute surface part of your contract, and a provider upgrade can then break your consumers through no change of yours.

outputs.tf
output "service_name" {
  description = "Name of the created service, for use in listener rules and alarms."
  value       = aws_ecs_service.this.name
}
 
output "task_role_arn" {
  description = "ARN of the IAM role the task runs as. Attach additional policies to this role outside the module."
  value       = aws_iam_role.task.arn
}
 
output "log_group_name" {
  description = "CloudWatch log group the container writes to."
  value       = aws_cloudwatch_log_group.this.name
}

Note the task role output. Giving consumers the role ARN, and documenting that they attach extra policies outside the module, is how you handle the long tail of permissions without growing a variable for every possible policy. Design escape hatches like this deliberately; a good module is opinionated in the middle and open at well-chosen edges.

One module, one state boundary

State hygiene starts with a blunt rule: a module instance and its state are a unit, and the module should contain resources that share a lifecycle. Resources that are created once and almost never change, a VPC, a cluster, do not belong in the same state as resources that change with every deploy, a service’s desired count or image tag. Mixed lifecycles in one state mean every apply touches a larger blast radius than the change warrants, plans get noisier, and a state problem in the volatile half endangers the stable half.

Composition is how you keep boundaries clean. A root configuration wires modules together rather than one module swallowing another’s domain:

main.tf
module "platform" {
  source  = "git::https://example.com/infra-modules.git//cluster?ref=v2.4.0"
  # ...
}
 
module "checkout" {
  source = "git::https://example.com/infra-modules.git//app-service?ref=v1.7.2"
 
  name    = "checkout"
  image   = "registry.example.com/checkout:1f3a9c2"
  cpu     = 1024
  memory  = 2048
 
  environment = {
    LOG_LEVEL = "info"
  }
}

Notice that the two modules are pinned to different versions. That is the point of separate modules with separate state: the platform team can move the cluster module forward without forcing every application team to re-validate their service module in the same change.

Inside the module, state hygiene has a second meaning: never reach outside. A module that reads data sources for resources it does not manage creates hidden coupling; when the external resource changes names or moves state files, the module breaks for reasons invisible in its own code. Pass identifiers in as variables instead, as the example does implicitly through composition, and let the root configuration own the wiring.

Refactoring safely also means knowing the removed and import blocks exist for the cases where resources genuinely leave or enter management. Reach for them deliberately rather than asking consumers to hand-edit state, because a module ecosystem that requires manual terraform state mv commands from its consumers will not stay an ecosystem for long.

Versioning that consumers can trust

Pin every module source to an immutable reference, a version tag or a commit, never to a branch. A branch is a promise that the code under your consumers changes whenever the module’s maintainers commit, which converts your release process into their surprise.

Version the module the way you would version an API, because that is what it is. A practical mapping for semantic versioning:

ChangeVersion bumpExample
New optional variable with a safe defaultMinorAdding an enable_execute_command toggle
New outputMinorExporting the log group ARN as well as the name
Bug fix with no interface changePatchCorrecting a tag the module applies
Required variable added, or default changedMajorMaking image require a digest
Variable or output removed or renamedMajorRenaming cpu to cpu_units
Internal resource restructureMajor (unless moved blocks make plans clean)Splitting one security group into two

The last row is the one teams get wrong. Consumers experience your module through their plans, not through your source code, so a change that produces a destructive plan is a breaking change even if every variable and output survived. Judge changes by the plan they produce against a real consumer state, not by the diff.

Publishing hygiene completes the contract. Tag releases, write release notes that name the plan impact (“no resource changes expected”, “the task definition will be replaced”, “state moves included via moved blocks”), and keep a changelog in the repository. Consumers deciding whether to upgrade read those notes; vague notes teach them to never upgrade, and a module nobody upgrades is a fork waiting to happen.

Testing and documentation, sized to the risk

You do not need an elaborate test pyramid for most modules, but you need something beyond “it applied once on my machine”. Native terraform test with mocked providers catches interface regressions cheaply: validation rules, output wiring, conditional resources. Reserve real applies for a small number of integration fixtures in a sandbox account, run on release rather than on every commit. Documentation is one README that states what the module is for, what it deliberately does not do, a minimal usage example copied from a fixture that CI actually applies, and the upgrade notes. An example that is not exercised rots silently, and a wrong example costs more trust than a missing one.

Verdict

A reusable module is a product with an interface, a release discipline, and a support burden, and it should be built like one. Keep the interface narrow and opinionated, with validation at the boundary and escape hatches at chosen edges. Keep one lifecycle per state and wire modules together at the root instead of nesting domains inside each other. Version by the plan your consumers will see, ship moved blocks with internal refactors, and test upgrades, not just fresh applies. Do those things and teams will build on your module; skip them and they will copy it once, rename it, and never look back, which is the market telling you the interface failed.

Written by Joan Ayebola

I’m a software engineer and technical writer with more than four years of experience building software and explaining how it works. I write tested tutorials, migration guides, and documentation that help developers understand a tool quickly and use it with confidence.

Keep reading