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.