Writing reusable modules
Modules are the reusable heart of a Terraform codebase — the "functions" of infrastructure. Writing a good module is a real skill: one that is well-scoped, cleanly parameterised, and documented is a joy to reuse; one that is a sprawling grab-bag with unclear inputs is a liability. This lesson goes deeper on writing reusable modules — their anatomy, good interfaces, and the judgement of what to make a module and what to leave inline.
Anatomy of a module
A Terraform module is any directory of .tf files, but a well-formed reusable module has a clear
structure, conventionally three files:
variables.tf— the module's inputs: the parameters that vary per use (a name, a CIDR, an instance size, a list of subnets). These are the module's interface — what a caller must or may provide.main.tf— the resources the module creates, using those variables. This is the implementation — the "how".outputs.tf— the outputs: values the module exposes for callers to use (an ID, an ARN, an endpoint). These let one module's results feed another's inputs.
A caller uses it with a module block, passing inputs and reading outputs:
module "vpc" {
source = "../../modules/vpc" # where the module lives (or a registry/git source)
name = "production"
cidr_block = "10.0.0.0/16"
az_count = 3
}
# another module consumes this one's output
module "cluster" {
source = "../../modules/eks-cluster"
vpc_id = module.vpc.id # the vpc module's output feeds the cluster module's input
}
This is a function call: inputs in, a defined thing created, outputs out. Composing modules by feeding outputs into inputs is how you build a whole environment from small pieces.
A good module interface
The quality of a module is mostly the quality of its interface — its variables and outputs. Good practices:
- Expose what varies, hide what does not. Make a variable only for what genuinely differs between uses (the name, the size, the environment); keep internal details inside the module. Too many variables makes a module as hard to use as too few — aim for a small, meaningful set of inputs.
- Give variables types, descriptions, and sensible defaults. A typed, documented variable with a good default is self-explanatory and safe:
variable "az_count" {
description = "Number of Availability Zones to spread subnets across"
type = number
default = 3
validation {
condition = var.az_count >= 2
error_message = "Use at least two AZs for resilience."
}
}
The validation block even lets a module reject bad input with a clear message — encoding good practice (at
least two AZs, from the networking module) into the module itself.
- Output what callers need — the IDs, ARNs and endpoints other modules or the environment will reference. If a caller has to reach inside your module to get a value, add an output for it.
- Document the module — a short README describing what it creates, its inputs and outputs, and an example call. A reusable module is used by people who did not write it; the docs are its usability.
The test of a good module: someone else can use it correctly from its variables, outputs and README alone,
without reading its main.tf. That is the same "usable by a stranger" standard as good code and good logs
throughout this course.
Consistent tagging in modules
A practical, high-value module habit that ties to the cost module: apply tags inside the module so
everything it creates is tagged automatically. Pass a tags variable and merge it onto every resource:
variable "tags" {
type = map(string)
default = {}
}
resource "aws_instance" "app" {
# ...
tags = merge(var.tags, { Name = "${var.name}-app" })
}
Now every environment that calls the module passes its standard tags (Environment, Team, Project — the
billing lesson), and every resource the module creates is tagged by construction. This is how you achieve the
"tag at creation, enforced by IaC" goal from the billing lesson: put the tagging in the module, and untagged
resources become impossible. It is one of the most worthwhile things a module does.
What to make a module — and what not to
Judgement about when to extract a module (the code-quality lessons, for infrastructure):
- Make a module when a group of resources is reused (a VPC used by every environment, a standard service pattern) or is a meaningful, nameable unit. Reuse and clear responsibility justify a module.
- Prefer a well-tested registry module for standard infrastructure — the official AWS VPC or EKS modules are thoroughly battle-tested; wrapping or calling one is usually better than writing your own from scratch.
- Do not over-modularise. Wrapping a single resource in a module, or building deep module hierarchies for their own sake, adds indirection without benefit — you jump through files to understand one resource. A module should earn its existence through reuse or genuine encapsulation, not exist because "everything should be a module." This is the premature-abstraction caution again: extract when there is real reuse, keep it inline otherwise.
The balance: modules for the reusable, meaningful units, with small clean interfaces, good defaults, validation, consistent tagging and a README — and not a module for every single resource. Good modules are what make a large Terraform codebase feel small; bad or excessive ones make a small one feel large.
Check your work
Anatomy: a module = variables.tf (inputs — the interface), main.tf (resources — the how), outputs.tf
(values for callers). Called like a function: module "x" { source, inputs }, read module.x.output; compose
by feeding outputs into inputs.
Good interface: expose only what varies (small meaningful input set — too many is as bad as too few); give
variables types/descriptions/defaults and validation (reject bad input with a clear message); output what
callers need; write a README. Test: a stranger can use it from its inputs/outputs/README without reading
main.tf.
Tagging in the module: take a tags variable and merge it onto every resource → everything the module
creates is tagged by construction (the billing lesson's "tag at creation via IaC"). Makes untagged resources
impossible.
What to modularise: extract when reused or a meaningful unit; prefer battle-tested registry modules for standard AWS infra; don't over-modularise (a module per single resource adds indirection). Extract for real reuse; keep inline otherwise.
Practice
- Describe the three-file anatomy of a module and what each file is for.
- Write a module
modulecall that passes inputs and consumes another module's output. - Write a variable with a type, description, default and a validation rule, and explain the benefit of each.
- Explain the "a stranger can use it from the interface alone" test for a good module.
- Add tag propagation to a module and explain how it enforces the billing lesson's tagging goal.
- Give an example of over-modularising, and explain when a group of resources should become a module.
Official documentation
- Terraform — Modules development — Writing reusable modules.
- Terraform — Input variables & validation — Typed, validated module inputs.
- Terraform — Output values — Exposing values from a module.
Next: managing dev, staging and production.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship