RizTech Academy logo
RizTech Academy
Terraform at ScaleLesson 3 of 540 min

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

  1. Describe the three-file anatomy of a module and what each file is for.
  2. Write a module module call that passes inputs and consumes another module's output.
  3. Write a variable with a type, description, default and a validation rule, and explain the benefit of each.
  4. Explain the "a stranger can use it from the interface alone" test for a good module.
  5. Add tag propagation to a module and explain how it enforces the billing lesson's tagging goal.
  6. Give an example of over-modularising, and explain when a group of resources should become a module.

Official documentation

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