All writing

Reusable infrastructure: what makes a Terraform module useful?

A useful Terraform module packages a coherent infrastructure decision behind a small, stable interface. Reuse comes from clear ownership, sensible defaults and predictable lifecycle behaviour, not merely from moving resources into another directory.

about 20 minutes min read

I create a Terraform directory containing:

main.tf
variables.tf
outputs.tf

Inside it, I define a network, several subnets and their route tables.

Technically, I have created a Terraform module.

That does not mean I have created a useful reusable module.

A directory becomes a module because Terraform can call it. A module becomes useful when another engineer can understand:

  • what problem it solves
  • which decisions it makes
  • which decisions remain with the caller
  • what resources it owns
  • how its lifecycle behaves
  • what changing an input will do
  • which outputs form its public contract
  • how safely it can evolve

Moving resources into a module can create the appearance of abstraction without reducing complexity.

BEFORE

twenty resources
inside one root module


AFTER

one module block
hiding twenty mysterious resources

The caller sees fewer lines.

The system has not necessarily become easier to reason about.

A useful Terraform module packages a coherent infrastructure decision behind a small, stable and understandable interface.

It makes the supported path easier while keeping its operational consequences visible.

A folder is not yet a useful abstraction

Every Terraform configuration is already a module.

The directory from which Terraform runs is the root module.

infrastructure/
├── main.tf
├── variables.tf
└── outputs.tf

A root module can call child modules:

module "network" {
  source = "../../modules/application-network"

  name = "bfstore-dev"
  cidr = "10.20.0.0/16"
}
ROOT MODULE
    │
    ▼
CHILD MODULE
    │
    ▼
RESOURCES

Module creation is therefore not primarily a syntax question.

The more important design question is:

Which infrastructure decisions deserve their own reusable boundary?

Not every group of nearby resources does.

A useful abstraction should make a capability easier to understand, safer to consume and more predictable to change. Hiding resource blocks without achieving those things merely moves the complexity.

Reuse and standardisation solve different problems

Suppose bfstore needs development, staging and production networks.

Each environment requires:

  • one virtual network
  • public subnets
  • private application subnets
  • private database subnets
  • route tables
  • flow logging
  • common tags

Copying the configuration three times creates obvious duplication.

Reuse can avoid implementing the same capability repeatedly.

Standardisation solves a related but different problem:

REUSE

avoid rebuilding the capability
for every consumer


STANDARDISATION

ensure consumers receive
the approved design

A module can be valuable even when it has only one current caller if it establishes a strong ownership, security or lifecycle boundary.

A heavily reused module can still be poor if its purpose is vague, its interface is unstable or its behaviour is surprising.

Repetition is therefore a signal that an abstraction may exist.

It is not proof that every repeated line belongs behind the same interface.

Development and production may look similar while having different:

  • owners
  • availability requirements
  • security controls
  • routing architecture
  • recovery obligations
  • change lifecycles

A useful module captures the stable decision shared between them and leaves meaningful differences visible to callers.

A module should package one coherent capability

A useful module has a clear reason to exist.

Examples might include:

multi-zone application network

encrypted versioned bucket

workload identity role

managed database foundation

Kubernetes application workload

central audit-log destination

Each represents a recognisable infrastructure capability.

A weaker module might create a network, database, registry, DNS zone, IAM roles, dashboards and an application runtime simply because one application happens to need all of them.

Those resources do not necessarily change for the same reason.

The network may remain for years.

The application may change several times each day.

The database contains durable state and may require protected deletion and restore procedures.

Identity roles may belong to a platform or security team.

A coherent module groups resources that genuinely belong to one decision.

The useful test is:

If this design decision changes, should these resources normally change together?

A network module might own:

virtual network

subnets

route tables

flow logs

network tags

A secure-storage module might own:

bucket

encryption configuration

versioning

lifecycle policy

access policy

audit logging

Resource count is not the important property.

Cohesion is.

The interface should expose caller-owned decisions

Consider a network module requiring these inputs:

module "network" {
  source = "../../modules/network"

  route_table_name_a = "bfstore-dev-private-a"
  route_table_name_b = "bfstore-dev-private-b"
  route_table_name_c = "bfstore-dev-private-c"

  flow_log_group_name = "bfstore-dev-flow-logs"

  application_subnet_name_a = "bfstore-dev-app-a"
  application_subnet_name_b = "bfstore-dev-app-b"
  application_subnet_name_c = "bfstore-dev-app-c"
}

The caller is specifying much of the module’s internal construction.

The resource blocks have been hidden, but their wiring has escaped through the variable list.

A stronger interface might ask for decisions the caller genuinely owns:

module "network" {
  source = "../../modules/application-network"

  name        = "bfstore-dev"
  environment = "dev"
  cidr        = "10.20.0.0/16"

  availability_zones = [
    "eu-west-2a",
    "eu-west-2b",
    "eu-west-2c",
  ]
}

The module can own routine implementation details such as:

  • derived resource names
  • route-table creation
  • subnet associations
  • flow-log configuration
  • standard tags
  • approved DNS behaviour

Address allocation illustrates why interface design depends on ownership.

If the caller owns the address plan, explicit subnet CIDRs may belong in the interface:

application_subnets = {
  eu-west-2a = "10.20.10.0/24"
  eu-west-2b = "10.20.20.0/24"
  eu-west-2c = "10.20.30.0/24"
}

If the platform owns a standard allocation scheme, the module may derive those subnets from the assigned network range.

Neither design is universally correct.

The important point is that ownership of the decision is explicit.

A good module interface describes supported infrastructure choices. A weak interface asks the caller to assemble the module’s internal machinery from outside it.

Supported designs should be easy and safe

Every input increases the number of configurations a module can represent.

Suppose a module exposes ten independent Boolean variables:

enable_public_subnets

enable_nat

enable_ipv6

enable_flow_logs

enable_dns

enable_database_subnets

enable_endpoints

enable_transit_attachment

enable_firewall

enable_extra_routes

Those switches can theoretically produce more than one thousand combinations.

Many may never have been intended, reviewed or tested.

MORE FLEXIBILITY
       │
       ▼
LARGER POSSIBLE STATE SPACE
       │
       ▼
MORE INTERACTIONS TO UNDERSTAND

A module should expose real supported variation.

It should not turn every provider argument into a public feature switch in pursuit of universal reuse.

Sometimes two focused modules are safer than one highly configurable module:

application-network

central-egress-network

The interface should also make the ordinary path safe.

Useful defaults might include:

  • encryption enabled
  • private access
  • versioning enabled
  • standard retention
  • common tags
  • deletion protection for durable resources

A dangerous default makes the shortest module call the riskiest one.

Some properties should not be configurable at all.

If a secure-storage module exists to guarantee encryption, this may be the wrong interface:

variable "enable_encryption" {
  type    = bool
  default = true
}

If disabling encryption is not a supported platform decision, the module should enforce it internally.

The design question is:

Is this an ordinary caller choice?

Or is it part of the guarantee
the module exists to provide?

Types, defaults and validation should reinforce that contract.

variable "environment" {
  type = string

  validation {
    condition = contains(
      ["dev", "staging", "prod"],
      var.environment
    )

    error_message = "Environment must be dev, staging or prod."
  }
}

A precise type communicates structure:

variable "subnets" {
  type = map(object({
    cidr              = string
    availability_zone = string
    purpose           = string
  }))
}

Variable validation protects the immediate input contract.

Guarantees that depend on several values or on provider-reported behaviour may also require preconditions, postconditions, check blocks, policy tests, plan inspection or integration tests.

The aim is not only to accept valid HCL.

It is to make supported designs easy and unsupported designs difficult to express.

Inputs and outputs form a public contract

A module interface includes more than variables.

Outputs tell consumers which parts of the capability they may depend upon.

A network module might expose:

output "network_id" {
  value = aws_vpc.this.id
}

output "application_subnet_ids" {
  value = {
    for zone, subnet in aws_subnet.application :
    zone => subnet.id
  }
}

output "database_subnet_ids" {
  value = {
    for zone, subnet in aws_subnet.database :
    zone => subnet.id
  }
}

Those outputs support downstream use without exposing every internal object.

A weaker module might publish the complete provider resources:

output "everything" {
  value = {
    network      = aws_vpc.this
    subnets      = aws_subnet.this
    route_tables = aws_route_table.this
  }
}

Consumers may then depend on implementation details that the module author hoped to change later.

Outputs should answer:

Which stable information do callers
need to use this capability?

They should not answer:

Which provider attributes happen
to exist inside the module today?

An output such as:

application_subnet_ids

behaves like an API.

Consumers depend on:

  • the output name
  • its type
  • stable keys
  • the meaning of its values

Renaming it breaks callers.

Changing it from a map to a list may break callers.

Quietly changing its meaning from application subnets to every private subnet may be worse.

A module output is not merely data. It is a promise to consumers.

A module contract includes lifecycle and evolution

A module’s contract is not only a schema.

It includes what happens over time.

Suppose a database module accepts:

storage_type = "standard"

Changing it to:

storage_type = "high-performance"

may update the database in place.

Changing the engine family, network or identifier may require replacement.

Reducing retention may remove recovery points.

Disabling deletion protection may permit a later destroy.

Useful module documentation should identify:

  • in-place updates
  • replacement-triggering inputs
  • disruptive changes
  • protected resources
  • migration requirements
  • deletion consequences
INPUT CHANGE
     │
     ▼
PLAN
     │
     ├── update
     ├── replace
     ├── destroy
     └── no change

Internal refactoring also affects lifecycle because Terraform tracks resource addresses.

Suppose:

aws_subnet.application

becomes:

aws_subnet.private_application

The cloud subnet itself may not need to change.

Terraform’s management address has changed.

Without an explicit move, a plan may propose destroying the old subnet and creating a new one.

moved {
  from = aws_subnet.application
  to   = aws_subnet.private_application
}

Module evolution is therefore a state-management concern, not merely code tidying.

Versioning communicates intended compatibility, but infrastructure compatibility includes more than input and output names.

A release can preserve every variable name and still be operationally breaking if it:

  • replaces a durable resource
  • changes a security boundary
  • alters a default
  • creates downtime
  • requires a data migration
  • changes resource addresses

A module version is not only a code version.

It is a version of an infrastructure contract.

The mechanism depends on the source:

  • registry modules use version constraints
  • Git sources use revision references
  • local modules move with the calling repository revision

In every case, automation should be able to identify the exact module code used for a plan.

A plan against existing or representative state is the most direct way to reveal how an upgrade affects infrastructure already under management.

It should be combined with targeted tests for guarantees and failure modes that a plan cannot prove.

FIXTURE PLAN

does the module produce
the expected shape?


CONSUMER PLAN

what will this real adoption
change or replace?


INTEGRATION TEST

does the resulting capability
behave as promised?

Providers, modules and state are different boundaries

A child module should declare which providers and versions it requires.

It should normally receive configured provider instances from its caller.

The root module chooses environment context such as:

  • account
  • region
  • credentials
  • assumed role
  • provider aliases
ROOT MODULE

selects account and region
      │
      ▼
CHILD MODULE

creates its capability there

Default provider configurations may be inherited.

Aliased providers should be declared and passed deliberately.

A reusable module that secretly configures credentials or assumes one particular account becomes difficult to compose.

Module boundaries also do not create state boundaries.

ROOT STATE
│
├── module.network
├── module.database
└── module.cluster

All child-module resources may still belong to one state.

MODULE BOUNDARY

code abstraction and interface


STATE BOUNDARY

ownership, permission
and change isolation

A module can express a code boundary without changing who may plan, apply or approve the state in which it is called.

The same network module may be called from a dedicated network root state or from a larger environment state.

Reusable code and operational ownership are separate decisions.

Avoid empty wrappers and universal frameworks

Module abstraction has an unhelpful extreme at each end.

THIN WRAPPER

adds little policy,
consistency or convenience


FOCUSED CAPABILITY MODULE

packages one coherent decision


UNIVERSAL FRAMEWORK

supports many unrelated architectures

A single-resource module can be valuable when it guarantees something useful:

encryption

versioning

approved access policy

retention

audit logging

deletion protection

A module that merely replaces one resource block with one module block may add ceremony without value.

At the other extreme, a module can grow until it supports every topology, engine, deployment model and security exception.

Its interface accumulates variables such as:

mode

subtype

legacy_mode

advanced_mode

custom_override

disable_standard_behaviour

Internal conditionals begin constructing unrelated architectures.

At that point, the module is becoming a framework.

Frameworks can be useful.

They also require strong ownership, extensive tests, migration tooling, compatibility policy, documentation and support.

A team should not create one accidentally while believing it has merely tidied a folder.

Composition keeps architecture visible

Smaller focused modules can be composed by a root module:

module "network" {
  source = "../../modules/application-network"
  # ...
}

module "catalog_identity" {
  source = "../../modules/workload-identity"
  # ...
}

module "catalog_workload" {
  source = "../../modules/kubernetes-application"

  subnet_ids = module.network.application_subnet_ids
  role_arn   = module.catalog_identity.role_arn
}
REUSABLE MODULES

provide focused capabilities


ROOT MODULE

describes environment composition

Composition is usually stronger than unlimited flexibility because it keeps relationships visible.

It also helps separate conflicting lifecycles.

FAST LIFECYCLE

application workload


SLOW, PROTECTED LIFECYCLE

database


SHARED ENTRY POINT

DNS

Resources that support the same application do not necessarily belong in the same module or state.

A module update intended to replace an application should not quietly put a durable database in the same blast radius.

Plans and tests support deliberate adoption

A shared module may have several consumers:

development

staging

production

A new release might add an output, change a default, introduce a resource, rename an input or alter a resource address.

Consumers need evidence about what the upgrade will do.

A safe adoption path might be:

static validation

contract and policy tests

fixture plan

development consumer plan

staging consumer plan

production consumer plan

Integration tests can create disposable infrastructure and verify behaviour such as:

Can the network be created?

Do the expected routes function?

Is public access blocked?

Can the module be destroyed safely?

Examples should also be executable.

examples/
├── minimal/
├── multi-zone/
└── production/

A minimal example answers:

What is the smallest valid module call?

A production example demonstrates the intended operating path.

An example that no longer works is documentation with a trapdoor beneath it.

Tests should focus on the guarantees the module claims to provide.

A module called secure-bucket should prove the security properties that justify its existence.

Documentation and ownership complete the product

Generated input and output tables are useful.

They are not complete documentation.

A module README should explain:

  • purpose
  • architecture
  • supported use cases
  • unsupported use cases
  • required providers
  • inputs and outputs
  • examples
  • lifecycle behaviour
  • upgrade guidance
  • security assumptions
  • ownership and support

A table can say:

enable_deletion_protection

type:
    bool

default:
    true

It cannot explain why the default exists, when changing it is acceptable, whether it affects existing resources or which recovery obligation it protects.

Documentation should preserve reasoning the HCL cannot express alone.

Naming also helps define the contract.

Names such as:

common

shared

base

core

reveal little.

Stronger names describe responsibility:

application-network

encrypted-versioned-bucket

kubernetes-workload-identity

mysql-service-database

A precise name helps callers recognise unsupported uses.

Every shared module also needs an owner.

Someone must review changes, maintain release guidance, investigate failures and support consumers when the abstraction meets production.

A compact bfstore example

Suppose bfstore needs similar workload networks for development, staging and production.

A focused contract might be:

PURPOSE

create a multi-zone workload network


CALLER OWNS

environment identity

assigned address range

availability zones

environment-specific metadata


MODULE GUARANTEES

approved subnet roles

standard routing

flow logging

DNS behaviour

required tags


OUTPUTS

network identity

public subnet identities

application subnet identities

database subnet identities


EXPLICITLY EXCLUDED

organisation-wide IPAM

transit networking

hybrid connectivity

clusters

databases

A minimal call might be:

module "network" {
  source = "../../modules/application-network"

  name               = "bfstore-dev"
  environment        = "dev"
  cidr               = "10.20.0.0/16"
  availability_zones = ["eu-west-2a", "eu-west-2b", "eu-west-2c"]

  tags = {
    project = "bfstore"
    owner   = "platform"
  }
}

The root architecture composes this capability with separate identity, cluster and database modules.

The relationships remain visible.

The network module remains understandable.

A useful-module review

Area Review question
Purpose Which coherent capability and repeated decision does the module package?
Ownership Who maintains it, and which root state should call it?
Interface Which decisions genuinely belong to callers?
Guarantees Which security, resilience and operational properties are enforced?
Lifecycle Which changes update, replace or destroy resources?
Contract Which inputs, outputs and meanings must remain stable?
Composition Does it integrate cleanly without absorbing unrelated capabilities?
Evolution How are refactors, versions and migrations handled?
Evidence Which plans and tests prove the claimed behaviour?
Limits Which use cases and escape hatches are explicitly unsupported?

A module is useful when its boundary remains clearer than the infrastructure it hides.

The mental model I am keeping

My earlier model was:

TERRAFORM MODULE

a reusable folder
containing resources

The stronger model is:

                     INFRASTRUCTURE DECISION
                               │
                               ▼
                         MODULE INTERFACE
                               │
                 ┌─────────────┼─────────────┐
                 │             │             │
                 ▼             ▼             ▼
              INPUTS        GUARANTEES      OUTPUTS
                 │             │             │
                 └─────────────┼─────────────┘
                               ▼
                       OWNED IMPLEMENTATION
                               │
                               ▼
                         REAL RESOURCES

A useful Terraform module is not measured by how many lines it removes from the root configuration.

It is measured by how much repeated decision-making it resolves without hiding the consequences operators still need to understand.

It does not attempt to make every infrastructure design possible.

It makes one approved design dependable.

The best module is therefore not the most flexible module.

It is the module whose purpose, interface and behaviour remain unsurprising after the person who wrote it has left the room.

A folder can contain reusable code.

A useful module contains a reusable decision.

References and further reading

Terraform modules overview Introduces root modules, child modules, module sources and composition.

Terraform module block reference Documents module calls, sources, versions, providers and dependency relationships.

Developing reusable modules Explains module structure, requirements, composition and publishing considerations.

Terraform input variables Documents variable types, defaults, validation and sensitive values.

Terraform output values Documents outputs and their role in exposing module information.

Refactoring Terraform modules Explains moved blocks and preserving resource identity during module evolution.

Terraform testing Introduces native tests for module behaviour, assertions and test runs.