skip to content

Modules

Packaging a piece of infrastructure so other teams can reuse it: inputs, outputs, versioned sources, and composition. Interviewers ask what belongs in a module and what does not, since over-abstracted modules are a common self-inflicted wound.

part ofTerraformoverview, primer and where to startread it →
on this pageshow

questions

16

In a Terraform repository, why is composing several small modules in one flat root configuration usually preferred over nesting modules three or four levels deep?

level: middleimportance: must knowfreq 62%

answer

  1. root config is the wiring layer
  2. depth costs plumbing, not logic
  3. every level re-declares and re-exports
  4. module.a.module.b.module.c addresses
  5. pass sibling outputs instead of nesting

basics

~20 s

Flat composition keeps the dependency wiring and the resource addresses visible in one place. Deep nesting threads every input and output through pass-through variables at each level, hides what is actually created, and makes review, targeting and refactoring much harder.

solid answer

~50 s

I treat the root configuration as the wiring layer: it calls a handful of purpose-built modules and feeds one module's outputs into the next module's input variables, so every dependency edge is explicit in one readable file. Depth costs real money. A value the deepest module needs must be declared as a variable and forwarded by every module between it and the root, and an id produced at the bottom must be re-exported by an `output` block at each level to be usable up top — one logical change becomes edits in four directories. Deep trees also bury the infrastructure: plan addresses grow to `module.platform.module.app.module.compute.aws_instance.web`, `-target` and console lookups get unwieldy, and a reviewer can't tell from the root what will be created. I aim for the root plus one, occasionally two, levels of children.

code

hcl · 14 lines
hcl
module "network" {
  source = "./modules/network"
  cidr   = "10.0.0.0/16"
}

module "database" {
  source     = "./modules/database"
  subnet_ids = module.network.private_subnet_ids
  identifier = "orders"
}

output "database_endpoint" {
  value = module.database.endpoint
}

go deeper

for a junior

Know that a root configuration calls child modules with module blocks, and that you connect them by passing module.NAME.output into another module's input variable. Be able to point at that reference as the thing that orders the work.

for a middle

Be ready to explain the mechanics of depth: a variable must be declared and forwarded at every level, an output re-exported at every level, and the full path shows up in plan and state addresses. Say why that plumbing is where defaults silently shadow intent.

for a senior

Show the production judgment: demonstrate dependency inversion — modules take ids rather than building their dependencies — and explain how flat wiring makes review, targeting and incident debugging tractable when someone is reading a plan at 3am.

for a principal

Own the estate-level tradeoff: where reuse layers are worth their versioning cost, when a shared low-level module's interface change becomes a coordinated release across teams, and why depth is often a symptom of missing module scope rather than a structuring win.

## What "composition" means in Terraform The directory you run `terraform plan` in is the **root module**. Any `module` block inside it calls a **child module** — another directory of `.tf` files — with a `source` argument and a set of input variables. Composition is the practice of having the root call several small child modules side by side and wire them together by passing one module's output into another's input. Nesting is the opposite shape: the root calls one module, which calls another, which calls another, and the real resources sit at the bottom of the tree. Both shapes produce the same infrastructure. The difference is entirely about what a human can see and change. ```hcl # root main.tf — flat composition, wiring visible in one file module "network" { source = "./modules/network" cidr = "10.0.0.0/16" } module "database" { source = "./modules/database" subnet_ids = module.network.private_subnet_ids } ``` The reference `module.network.private_subnet_ids` is not just data passing: it is what creates the dependency edge in Terraform's graph, so the network is created before the database without anyone saying so explicitly. ## Depth taxes every value that crosses it Suppose the deepest module needs a new input, `kms_key_arn`. In a flat layout you add one variable and pass it in the root. Three levels down you add a `variable` block in the leaf module, another in its parent, another in the grandparent, and an argument at each `module` call site — four files edited for one value, none of which contains any logic. The same applies in reverse: an id produced at the bottom is invisible to the root until every intermediate module re-exports it with its own `output` block. That plumbing is where bugs hide. An intermediate module usually has a `default` on the forwarded variable, so a value the root forgot to pass is silently replaced by whatever a middle layer decided months ago, and the plan looks fine. ## The graph you can no longer see Module boundaries are not evaluation boundaries. Terraform flattens the whole tree into a single dependency graph and a single state file; a module is a namespace, not a sandbox. So depth buys no isolation — it only removes information: - **Review.** A pull request that changes the root gives no clue what exists three levels down; the reviewer has to open four directories to answer "what does this create?". - **Addresses.** Plan output, errors, `-target=...` and state addresses all carry the full path, `module.a.module.b.module.c.aws_instance.web`. Anything you do by address becomes long and error-prone. - **Blast radius.** Changing a shared low-level module's interface forces a matching change in every level above it, and if those levels are separately versioned, a coordinated release train for a one-line change. ## Where nesting is still correct The rule is "shallow", not "never". One level of internal reuse inside a module is normal and healthy — a `service` module that calls a small shared `iam-role` module because every service needs the same role shape. What goes wrong is nesting used as *organisation*: splitting the root into layers because the file felt long, or building a `platform` module whose only job is to call three other modules with arguments it forwards unchanged. That layer adds a name and a version and no behaviour. ## Dependency inversion is the flat alternative The move that keeps configurations flat is to stop letting a module build its own dependencies. Instead of a `database` module that creates a VPC because it needs subnets, the module takes `subnet_ids` as an input. The root creates the network once and hands its ids to every consumer. Each module then becomes independently usable — you can point the database module at an existing VPC in a test account — and the composition, the part that differs between environments, lives in one file you can read top to bottom. ## What an interviewer is listening for A good answer names a concrete cost, not a style preference: pass-through variables at every level, outputs re-exported at every level, long addresses, and reviewers who cannot see the infrastructure from the entry point. A strong answer adds the counterweight — that one level of nesting for a genuinely shared primitive is fine, and that the fix for a long root file is usually better-scoped modules, not another layer above them.

  • Is there a case where you would deliberately nest one module inside another?
    Yes — when a module genuinely needs a shared primitive for every instance of itself, such as a `service` module that calls a small `iam-role` module. That is one level of internal reuse with real behaviour behind it. What I avoid is a wrapper layer whose only job is to forward arguments to other modules, because it adds a version and a name without adding a decision.
  • Two sibling modules both need the same VPC. How do you wire that without one calling the other?
    Invert the dependency: neither module creates the VPC. The root calls a `network` module once and passes `module.network.vpc_id` and the subnet ids into both consumers as input variables. The reference itself creates the ordering edge in the graph, both modules stay usable against a pre-existing network, and the relationship is visible in the root instead of buried in a child.
  • Does keeping the tree flat give each module its own state or its own plan?
    No. Every module in a single root configuration shares one state file and one dependency graph — a module is a namespace, not an isolation boundary. Separating blast radius means separate root configurations with separate state, which is a different decision from module depth. Flatness buys readability and easier addressing, not isolation.

saying these in an interview costs you the question

  • Claims each nested module gets its own state file
  • Says nesting isolates blast radius or limits the plan
  • Splits the root into layers just because the file is long
  • Lets a module create its own VPC instead of taking ids
  • Thinks configuration file order controls apply order

context

open as a page

In a Terraform module block whose source is a Git repository, how do you pin the module to a specific version, and why can't you use the version argument?

level: middleimportance: must knowfreq 75%

basics

~20 s

Terraform's version argument works only for registry module sources. A Git-sourced module is pinned inside the source string itself, with a ?ref= query parameter naming an immutable tag or commit SHA. A branch name in ?ref= is not a pin.

open as a page

A caller of your Terraform module needs the ID of a subnet the module creates, but the module declares no output for it. Why can't the caller reference the resource directly, and what is the correct fix?

level: middleimportance: must knowfreq 65%

basics

~20 s

Resources inside a child module are not addressable from outside it: module.<name> resolves only declared outputs, so module.network.aws_subnet.private is an error. The fix is to add an output to the module — its outputs are its public API, and adding one is a backward-compatible change.

open as a page

In Terraform, what is the difference between the root module and a child module, and what can only the root module do?

level: middleimportance: must knowfreq 70%

basics

~20 s

The root module is the directory Terraform is run in; a child module is a directory called from a module block. Only the root takes backend configuration, tfvars and CLI variable values, and the provider configuration for the run.

open as a page

What kinds of values can the source argument of a Terraform module block take, and how does Terraform decide how to fetch each one?

level: juniorimportance: should knowfreq 60%

basics

~20 s

Terraform accepts local paths beginning with ./ or ../, module registry addresses of the form namespace/name/provider, Git and Mercurial repositories, plain HTTP or S3/GCS archives. Terraform inspects the string's shape and prefix to pick the fetcher.

open as a page

Does Terraform care whether your configuration is split into main.tf, variables.tf and outputs.tf, and why is that layout worth keeping anyway?

level: juniorimportance: should knowfreq 55%

basics

~20 s

Terraform does not care. It loads every .tf file in the directory and orders work from the dependency graph, so file names are purely a human convention. The convention survives because it puts a module's interface — its inputs and outputs — where a consumer can find it without reading the implementation.

open as a page

In Terraform, what does adding `for_each` to a `module` block do, and how do you reference the resulting instances and their outputs?

level: middleimportance: should knowfreq 52%

basics

~20 s

for_each on a module block instantiates the entire module once per map key or set element. Each instance is addressed as module.NAME["key"], each.key and each.value are available inside the block, and the module's outputs become a map keyed by those same keys.

open as a page

Your team keeps several Terraform modules in one Git repository. How do consumers reference just one of them, and how should you tag releases so each module can be pinned independently?

level: middleimportance: should knowfreq 42%

basics

~20 s

Consumers point at the repository and select the module with a double-slash subdirectory, for example git::https://host/repo.git//modules/vpc?ref=…. Because a Git tag covers the whole repository, tag per module with a prefixed name such as vpc/v1.2.0 to version them independently.

open as a page

A shared Terraform child module declares its own `provider "aws"` block so it can pin a region. Why is that discouraged, and what should the module and its caller do instead?

level: seniorimportance: should knowfreq 44%

basics

~20 s

A module owning its provider configuration cannot be used with count, for_each or depends_on, cannot be reused in another region, and becomes hard to remove — deleting the module block takes the provider config with it, leaving resources Terraform can no longer destroy. Declare requirements and let the caller pass configurations in.

open as a page

In Terraform, what are the consequences of putting `depends_on` on a `module` block instead of wiring modules together through their outputs?

level: seniorimportance: should knowfreq 36%

basics

~20 s

It makes every object in the module wait for everything in the target, which serialises work that could run in parallel and hides the relationship from the code. It also defers data sources inside the module to apply time, so the plan fills with "(known after apply)".

open as a page

A shared Terraform module is pinned at v1.4.0 by twenty root configurations, and you need to ship a breaking v2.0.0. How do you roll that out?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Publish v2.0.0 as a new immutable version without touching v1, keep v1 patchable during a deprecation window, then move consumers one at a time — pinning each, reading its plan, applying, and only then proceeding. Never edit a released tag.

open as a page

When designing a reusable Terraform module, how do you decide which input variables get a default and which are left required?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Omit the default when the caller must decide — identity, placement, and anything with security or cost consequences — so Terraform refuses to run without it. Give a default only where one value is correct for every caller and being wrong is cheap and visible.

open as a page

How deep should a Terraform module tree nest, and what does each additional layer cost you?

level: principalimportance: should knowfreq 38%

basics

~20 s

Terraform imposes no limit, but keep the tree shallow — usually one layer of reusable modules under a root, rarely two. Every extra layer adds pass-through variables and outputs at each level, lengthens every resource address, and puts more distance between a reviewer and the resources being changed.

open as a page

What belongs in a reusable Terraform module's examples/ directory, and how do you keep the input and output tables in its README accurate?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

examples/ holds complete, runnable root modules — one per usage pattern — that CI can init, validate and plan, so a broken interface fails the build. README tables are generated from the description fields on variables and outputs by a tool such as terraform-docs, with the generation checked in CI rather than done by hand.

open as a page

When is writing a Terraform module premature abstraction rather than useful factoring, and how do you decide?

level: principalimportance: nice to knowfreq 40%

basics

~20 s

A module earns its place when it encodes a decision — defaults, naming, tagging, or several resources wired together — that would otherwise be repeated and drift. A module that wraps one resource and forwards every argument unchanged adds a version, an indirection and a release process while adding no behaviour.

open as a page

For an organisation's internal Terraform modules, how would you decide between publishing them to a private module registry and having consumers reference Git URLs directly?

level: principalimportance: nice to knowfreq 32%

basics

~20 s

Choose a private registry when you want queryable version lists, constraint resolution with the version argument, and published documentation; choose Git sources when you want no extra infrastructure and accept that every consumer pins a ref by hand.

open as a page