Infrastructure as Code

Modules: Reuse Without Hiding

A module is a named boundary with an interface — a web service as load balancer plus compute plus identity plus alarms, instantiated four times. It earns its place when it removes a decision. A module that wraps one resource and exposes twenty variables is worse than the resource.

The question this answers

Infrastructure question

When does grouping resources behind an interface make infrastructure clearer, and when is it just a second syntax for the same resource?

Application requirement

Four services need the same production shape — a load balancer, an autoscaling compute group, a workload identity with a scoped policy, and the alarms that page someone. Getting one of those four pieces wrong on the fifth service must not be possible.

What it provides

A single named unit that encodes a set of correct-by-default decisions, so instantiating it produces a service that is monitored, scoped and reachable without the caller rediscovering how.

Application RequirementInfrastructure RequirementComputeNetworkStorageIdentityDeploymentScalingReliabilityObservabilitySecurityCostTrade-offs

A module is a decision, packaged

The useful mental test is: what does a caller no longer have to decide? A good web-service module says — for this organisation, a service means a load balancer with these health check settings, a compute group with these scaling bounds, a workload identity with a policy scoped to its own resources, log shipping, and four alarms including the one on target health that people forget. The caller supplies a name, an image, a size class and a port. Everything else is a decision the module already made, once, correctly.

That is genuine leverage, and it is mostly organisational rather than technical: the fifth service gets the alarm that the first four only have because someone remembered. The module is where "how we run things here" stops being tribal knowledge.

The interface is the whole design. Inputs should be the things that legitimately differ between callers — name, image, size, port, environment. Anything a caller should not vary is not an input. Every variable you add is a decision handed back to the caller, and a module whose interface exposes every underlying argument has given the decisions back while keeping the indirection.

1module "checkout" {
2 source = "git::ssh://git@internal/modules/web-service?ref=v4.2.0" # pinned: an unpinned ref applies whatever main is today
3
4 name = "checkout"
5 image = var.checkout_image # an immutable digest, promoted, not rebuilt
6 size_class = "medium" # not cpu/memory/instance_type — a class the platform team defines
7 port = 8080
8 environment = var.environment # drives replica bounds, logging retention and alarm routing
9}
10
11# What the module created without being asked:
12# - a load balancer target group with a readiness path and sane thresholds
13# - a compute group with min/max derived from environment, not copy-pasted
14# - a workload identity whose policy is scoped to this service's own resources
15# - log shipping, and alarms on target health, error rate, saturation and deploy failure
16
17output "checkout_url" { value = module.checkout.url }
The interface a web-service module should have: five inputs, and the correctness comes for free.

The abstraction that costs more than it saves

The failure mode is specific and common enough to have a shape. Someone wraps a single resource in a module "for consistency". To be useful to the second caller it needs one more variable. Then another. Eighteen months later the module has twenty-three inputs, half of them pass-through, a locals block computing conditional defaults, and a caller has to read the module source to know what any of it does. The indirection has been paid for in full and nothing has been bought: the caller makes every decision they would have made anyway, one layer further from the documentation.

It is worse than the plain resource in a way that is easy to miss. The provider documentation describes the resource; nothing describes your module except its source. A new engineer can look up cloud_object_bucket. They cannot look up module "bucket" v3.1. You have replaced a documented interface with an undocumented one and called it standardisation.

Two heuristics that hold up. If the module has no more than one or two resources and its inputs map roughly one-to-one onto their arguments, delete it and use the resource — a shared locals file or a default variable gets you the consistency without the layer. And if you cannot state in one sentence which decisions the module makes on the caller's behalf, it does not make any.

A wrapper that decides nothing — twenty pass-through variables and a source file to read
# modules/bucket/main.tf
variable "name"                {}
variable "versioning"          { default = null }
variable "encryption"          { default = null }
variable "lifecycle_days"      { default = null }
variable "logging_target"      { default = null }
variable "public_access_block" { default = null }
# ... 17 more, all optional, all pass-through

resource "cloud_object_bucket" "this" {
  name       = var.name
  versioning = var.versioning
  encryption = var.encryption
  # every argument forwarded, no decision made
}
Either use the resource directly, or make the module actually decide
# Option A — use the documented resource. Consistency via a shared default.
resource "cloud_object_bucket" "assets" {
  name       = "assets-${var.environment}"
  versioning = true
  encryption = { kms_key = local.default_key }
}

# Option B — a module that is worth its layer, because it takes a stance:
module "audit_bucket" {
  source = "git::ssh://git@internal/modules/audit-bucket?ref=v2.0.0"
  name   = "orders-audit"
  # decided for you: versioning on, object lock 7 years, KMS with a key policy,
  # public access blocked, access logging to the security account, no delete path.
}

Option A is one documented resource. Option B removes five decisions the caller would otherwise get wrong, and its name says what it is for. The wrapper on the left does neither: it costs a layer of indirection and returns every decision to the caller.

Versioning a module is versioning an API

The moment a module has more than one caller, its interface is a published contract and changing it is a migration. Rename an input and every caller breaks at plan time — which is at least loud. Change a *default* and every caller changes behaviour silently on their next apply, which is not. A module whose default replica count moves from 2 to 3 has just changed the bill and the capacity of every service that uses it, in a diff nobody in those services reviewed.

So: pin module sources by version tag, never by branch. Treat a changed default as a breaking change even though the tool does not. Publish what changed, and migrate callers deliberately rather than all at once. The compatibility rules here are the ones the API Design domain teaches for any published interface; infrastructure does not get an exemption because the consumers are internal.

The blast radius is the other half. A module used by twenty services, applied on twenty separate schedules, means a bad version reaches production twenty times over several weeks — which is slow enough to catch and slow enough to forget you started. Roll forward one caller, verify, then widen.

QuestionEarns itDoes not earn it
What decisions does it make?Health check path, scaling bounds, IAM scope, alarmsNone — every argument is an input
How many inputs?A handful, all things that legitimately differ per callerTwenty-plus, mostly optional pass-through
Resources inside?Several that must be wired together correctlyOne
Can a newcomer read the caller and understand it?Yes — the inputs are domain words like size_classNo — they must open the module source
What breaks if it is wrong?One correctness bug fixed once, for everyoneNothing was centralised, so nothing was fixed
Does this module earn its layer?

Key points

  • A module earns its place by removing decisions — health checks, IAM scope, alarms — not by grouping resources.
  • A wrapper around one resource with twenty pass-through variables is worse than the resource, because the resource has documentation and the module does not.
  • The interface is the design: an input is a decision handed back to the caller.
  • Pin module sources by version tag. A changed default is a breaking change even though nothing errors.
  • A module with twenty callers has a twenty-service blast radius, delivered gradually as each caller applies.

The loop, answered

Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.

How it works
  • A module is a directory of configuration with declared inputs and outputs; instantiating it inlines its resource graph into the caller's graph.
  • Resource addresses become nested (module.checkout.cloud_instance.api[0]), which is why moving a resource into a module requires a state move rather than just an edit.
  • The caller's state holds the module's resources; the module has no state of its own.
  • Sources are fetched at initialisation from a registry, a git ref or a local path, and pinned by whatever the ref says — including "whatever main is today", if you let it.
  • Outputs are the only sanctioned way to read a value out; reaching into a module's internal addresses works and immediately couples the caller to its implementation.
What you still own
  • You own the module's interface and its compatibility, exactly as you would own a published API.
  • You own the migration when an interface changes under existing callers, including the callers that will not apply for a month.
  • You own version hygiene: knowing which callers are on which version, and finishing migrations rather than leaving three versions live.
  • You own state moves when refactoring resources into or out of a module — an edit that looks cosmetic and is not.
  • You own the defaults, which quietly set the capacity and the cost of every service that instantiates the module.
How it fails
  • A branch-pinned source: a merge to the module's main branch changes the next apply of every unrelated service.
  • A changed default silently altering capacity or cost across every caller, in a diff none of them reviewed.
  • Interface bloat: a module that grew a variable per caller until it is a worse version of the resource it wraps.
  • Refactoring resources into a module without a state move, producing a plan that destroys and recreates everything it moved.
  • A module owned by nobody: the platform team that wrote it moved on, twelve services depend on it, and its provider constraint now blocks an upgrade.
How it scales
  • Modules scale team knowledge, not resource count — the constraint that binds is how many people understand the interface.
  • Nesting depth is what makes a plan unreadable: three levels of module and the resource address is longer than the change.
  • Caller count is the migration cost multiplier. Ten callers is a coordinated change; a hundred is a programme with a deprecation timeline.
Security
  • Modules are the right place to encode security defaults: no public access, encryption on, scoped identity, logging enabled. One correct decision, inherited everywhere.
  • Third-party modules run in your plan with your provider credentials. Pin them by version, review the source, and treat the registry as a supply-chain dependency. See The Infrastructure Supply Chain.
  • A module that accepts an IAM policy document as an input has handed the security decision back to the caller — which is sometimes right and should be a conscious choice.
  • A permissive default in a widely used module is a systemic exposure: one wrong value, inherited by every service that trusted it.
Cost shape
  • Module defaults are bill multipliers. A default instance size or replica count propagates into every environment that instantiates it, including the twelve development copies.
  • Environment-aware defaults inside the module — smaller in development, redundant in production — are the cheapest cost control available, because they apply without anyone deciding.
  • The cost of a bad module is engineering time: a migration across every caller, which is why the interface deserves the design attention up front.
What to watch
  • Module versions in use across the repository — three live versions means an unfinished migration.
  • Plan noise per caller after a module change, which is the practical measure of whether a change was really non-breaking.
  • Whether the alarms and log shipping the module promises actually exist on each instantiation; a module that creates a monitor nobody routes is a checkbox.
  • The signal that lies: "all services use the standard module". They may use five versions of it with different defaults.
Simpler alternatives
  • Plain resources with a shared defaults file. For a handful of similar services this is clearer than a module and has provider documentation behind it.
  • Copy and paste, deliberately, for two or three instances. Duplication is cheaper than the wrong abstraction, and the third copy is when the shape finally becomes clear.
  • A code generator or template that emits plain configuration, when you want a starting point rather than a permanent coupling. The output is readable and the caller can diverge.
  • A higher-level platform abstraction — an internal service catalogue or a PaaS — when what you actually want is for application teams not to write infrastructure at all.
What adopting this costs
  • Buys correct-by-default services; costs an interface to maintain and migrations to run when it changes.
  • Buys centralised security and monitoring defaults; costs a single point of systemic exposure if a default is wrong.
  • Buys readable callers; costs readable plans — nested addresses make a large plan harder to scan, which interacts badly with Reading a Plan Before You Apply It.

What people believe, and what is true

Claim

Repeated configuration should always become a module.

Reality

Repetition is only a problem when the repeated thing must stay consistent. Two similar services that will diverge are better as two files than as one module with a conditional.

Claim

A module makes infrastructure simpler.

Reality

It moves complexity behind an interface. That is a win only when the interface is smaller than what it hides — which a twenty-variable wrapper is not.

Claim

Bumping a module version is a safe, non-breaking change.

Reality

A changed default alters behaviour without erroring. Read the plan of the first caller before rolling it to the rest.

Apply it