Terraform path · At scale · Topic 11 of 14

Modules and composition

Not started

A **module** is a directory of .tf files. The one you run terraform plan in is the **root module**; it can call **child modules** with module blocks, passing values into their variables and reading their outputs. Modules are Terraform's unit of reuse: one module for "a service with its queue, alarms and IAM role", called once per service, instead of the same 200 lines pasted ten times.

Why it matters: Modules are how a team stops copy-pasting the same network or service setup into ten directories. Good ones encode decisions once; bad ones become a second, worse cloud API.

  1. 1Every directory is a module

    The directory you run Terraform in is the root module. A module block calls another directory, from a local path, a registry or Git.

  2. 2Inputs in, outputs out

    A module's variables are its parameters and its outputs are all the caller can see. Keep both small and meaningful.

  3. 3Compose, do not nest deeply

    Prefer a flat root that wires several modules together over modules that call modules that call modules.

  4. 4Pin module versions

    Registry modules take a version constraint; Git sources take a ref. Upgrades should be deliberate changes.

1

How it works

The explanation, with examples you can run.

A small module

# modules/service/main.tfvariable "name" { type = string }variable "env"  { type = string } resource "random_pet" "suffix" {  prefix = "${var.env}-${var.name}"} resource "local_file" "manifest" {  filename = "${path.root}/out/${random_pet.suffix.id}.txt"  content  = "name=${random_pet.suffix.id}\n"} output "id" { value = random_pet.suffix.id }
# main.tf (root)locals {  services = toset(["api", "worker"])} module "service" {  source   = "./modules/service"  for_each = local.services   name = each.key  env  = "dev"} output "ids" {  value = { for k, m in module.service : k => m.id }}

Applied with Terraform 1.16.6:

Apply complete! Resources: 4 added, 0 changed, 0 destroyed. Outputs: ids = {  "api" = "dev-api-literate-dingo"  "worker" = "dev-worker-workable-duck"}

and the state addresses include the module path and key:

module.service["api"].local_file.manifestmodule.service["api"].random_pet.suffixmodule.service["worker"].local_file.manifestmodule.service["worker"].random_pet.suffix

Sources and versions

module "vpc" {  source  = "terraform-aws-modules/vpc/aws"   # public registry  version = "~> 6.0"} module "alarms" {  source = "git::https://github.com/example/tf-modules.git//alarms?ref=v2.3.1"}

Local paths need no version. Registry modules take version; Git sources take a ref (a tag or commit). terraform init downloads them into .terraform/modules. Read third-party modules before using them: they run with your credentials and decide what your infrastructure looks like.

Design guidelines

  • One purpose per module, with a few meaningful inputs and sensible defaults. A module that exposes every argument of every resource is not an abstraction.
  • Composition over nesting. Let the root module connect modules by passing outputs to inputs (vpc_id = module.network.vpc_id), rather than modules that create their own networks. Dependencies stay visible and each piece stays testable.
  • No provider blocks in reusable modules: declare required_providers and receive configurations from the caller (providers = { aws = aws.us }).
  • Stable addresses: renaming resources inside a module affects every caller. Add moved blocks when you refactor, and release a new version.
  • Document and test: variable descriptions, an example directory, and terraform test files (see the testing topic) make a module trustworthy.
2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Which is a sign of a poor module?
  2. 2.How do you create one instance of a module per service name in a set?
  3. 3.Should a reusable module contain a provider block with a Region?

0 of 3 right so far. Get all three to finish this topic.