Chapter 7: Modules
Topic 41

What a Module Is

ConceptModules

A module is any directory of .tf files, which means you have been writing modules since your first configuration without calling them that. The directory you run terraform apply in is the root module; the moment it calls another directory with a module block, that directory becomes a child module. There is no special syntax, no marker file, nothing that declares "this is a module" — the word names a role a directory plays, not a new kind of thing.

The only real idea here is encapsulation. A child module is a black box: input variables go in, outputs come out, and the caller never reaches inside. That boundary is what lets the six interdependent resources of the Hatch ingest pipeline — a bucket, a Pub/Sub topic, a Cloud Run service, a BigQuery dataset, and a service account — become one thing you call once per event type, instead of a block of copy-paste you maintain in ten places.

Every Config Is Already a Module

Nothing in a directory marks it as a module. The root module is just the directory you run Terraform from; a child module is a directory another module references with a module block. Modules are the unit of reuse, not a new concept layered on top of resources — when you grasp that a module is a directory plus a calling convention, the rest of the chapter is mechanics.

This is why "should I make a module" is the wrong first question. The real question is whether a group of resources will be instantiated more than once, or needs a consistent contract across teams. If the answer is no, the resources can live in your root module exactly as they have all along; the module concept costs you nothing until you reach for it.

Root vs Child

The root module is the one place that owns the provider and backend blocks, and it is where state lives — there is exactly one root per working directory. A child module declares neither: it inherits the provider configuration from its caller, and it has no backend because state belongs to the root. Putting a backend block inside a child is the most common beginner error in the whole chapter, because the block is silently ignored rather than erroring.

The snippet below is a complete child module — a directory holding variables.tf, main.tf, and outputs.tf — and a root module that calls it. Notice the child has no provider and no terraform { backend }; the root supplies both, and the child borrows the google provider the root configured.

A root module calling a child — neither special, both just directories
# root: main.tf — owns the provider and backend
provider "google" {
  project = "hatch-pipeline-prod"
  region  = "us-central1"
}

module "ingest_pipeline" {
  source     = "./modules/ingest-pipeline"   # a child = a directory it references
  project_id = "hatch-pipeline-prod"
  event_name = "clickstream"
}

# child: modules/ingest-pipeline/main.tf — no provider, no backend
resource "google_storage_bucket" "raw" {
  name     = "hatch-${var.event_name}-raw"
  location = "US"
}

Run terraform apply in the root and Terraform walks both directories as one graph, but the responsibilities never blur: the root decides where state lives and which credentials to use, the child only describes resources.

One module tree, descending responsibilities
root module
owns provider + backend + state
child module
no backend, inherits providers
resources
bucket, topic, dataset — private internals

Encapsulation Is the Whole Point

The caller sees only the child's input variables and its outputs. The resources, locals, and data sources inside are private — invisible across the boundary. That is what makes a module worth the indirection: the ingest-pipeline module can rearrange its internals, swap a google_storage_bucket for a different layout, or add an internal data source, and no caller changes a line as long as the inputs and outputs hold.

Encapsulation also means you cannot reach in. Referencing module.ingest_pipeline.google_storage_bucket.raw.url fails — the resource address is private to the child. You can read only what the child explicitly declares as an output. That restriction feels limiting until you realize it is exactly what keeps the boundary a real contract instead of a suggestion.

Why Modules Exist: The Second Pipeline

The Hatch ingest pipeline is six resources wired together: the raw bucket, the Pub/Sub topic events land on, the Cloud Run service that processes them, the BigQuery dataset they load into, and the service account that ties the identity together. As one pipeline in your root module, that is fine. The moment you want a second pipeline for a second event type, you face a fork: copy those six resources and maintain two divergent copies, or make them a module and instantiate it twice with different inputs.

A module turns that fork into one line of difference. event_name = "clickstream" for the first, event_name = "transactions" for the second, and a fix to the pipeline shape is made once. The copy-paste path means a fix has to be applied in every copy, and one will inevitably be missed — that drift is the exact problem the module concept exists to kill.

The Module Tree

terraform apply walks a tree rooted at your working directory, and that tree shows up in state addresses and plan output. A resource inside the child reads as module.ingest_pipeline.google_storage_bucket.raw — the module.<name> prefix tells you which module produced it. Reading those addresses as a tree is how you trace a plan line back to the directory that owns it, which becomes essential once modules call modules.

Root Module vs Child Module

Root module — the directory you run terraform in. It holds the backend, the provider configuration, and the state, and there is exactly one per working directory. This is where credentials and state location are decided.

Child module — any directory called via a module block. It has no backend, inherits providers from its caller, and can be instantiated many times with different inputs. Confusing the two leads to putting a backend block in a child, where it is silently ignored.

Common Mistakes
  • Putting a terraform { backend } block inside a child module — only the root module's backend is honored, so the block does nothing and signals a misunderstanding of where state lives.
  • Treating a module as a namespace you can reach into — referencing module.ingest_pipeline.google_storage_bucket.raw.url directly fails; you can only read what the child explicitly outputs.
  • Declaring a provider block inside a reusable child module — it makes the module impossible to instantiate twice and fights the provider-passing model covered in Topic 46.
  • Believing you need a module before you have repetition — wrapping a single one-off pipeline in a module on day one adds an indirection layer with no payoff.
  • Splitting a config into dozens of one-resource modules "for tidiness" — each adds a state-address level and a boundary to cross for no encapsulation benefit.
Best Practices
  • Treat the directory you run apply in as the root module and keep provider and backend blocks there, never in a child.
  • Reach into a child module only through its declared outputs, so the boundary stays a real contract instead of a leaky abstraction.
  • Reach for a module the moment you need the second copy of a resource group, not before — repetition justifies the abstraction, anticipation does not.
  • Read state and plan addresses as a tree (module.<name>.<resource>) to know which module produced what when a plan surprises you.
  • Structure a child as variables.tf, main.tf, and outputs.tf so its interface and its internals are visible at a glance.
Comparable tools Pulumi component resources — a class that encapsulates child resources Helm · Kustomize charts and bases as the Kubernetes encapsulation unit Ansible roles as the configuration-management equivalent

Knowledge Check

What actually makes a directory a Terraform module?

  • Nothing special — any directory of .tf files is a module; root vs child is just the role it plays
  • A module.tf manifest file at its root that explicitly registers the directory as a Terraform module
  • A terraform { module } block at the top of one of its .tf files
  • Publishing the directory to the public Terraform Registry first

Why does a backend block inside a child module do nothing?

  • State belongs to the root module, so only the root's backend is honored and the child's is silently ignored
  • Child modules keep their state in a separate per-module state file that the configured backend cannot reach
  • Backend blocks only take effect once the module has been published to a remote registry
  • It errors out at init, forcing you to remove the block before you can proceed

A caller wants the URL of a bucket created inside the ingest-pipeline child. How do they get it?

  • The child must declare it as an output; reaching module.ingest_pipeline.google_storage_bucket.raw.url directly fails
  • Reference the resource address inside the child directly, since modules are open namespaces you can freely read into
  • Read the value straight out of the child module's own state file by its resource path
  • Add a provider block to the child module to expose its internal resources to the caller

When is the right time to extract the Hatch pipeline's six resources into a module?

  • When you need a second pipeline for a second event type, so the alternative is copy-pasting six resources
  • On day one, well before the first pipeline is even applied once, to keep the root future-proof from the start
  • Only after you have published the resource set to the public Terraform Registry
  • Never — pipelines should always stay inline in the root module, not a child

You got correct