Skip to content
intermediate Phase 7 · Module Design and the Module Registry

Module Block Anatomy

Declaring module calls with source, version, and passing input arguments.

50m
0 problems
Topic Progress 0%

Reading One Module Call Top to Bottom: Label, source, version, Inputs

You inherit a root module that builds a network, and the whole thing is one module call:

module "network" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.5.0"

  name = "${var.project}-${var.env}"
  cidr = var.vpc_cidr

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = [for i in range(3) : cidrsubnet(var.vpc_cidr, 8, i)]

  enable_nat_gateway = true
  single_nat_gateway = var.env == "prod" ? false : true

  tags = local.common_tags
}

Every part of the module system is visible in that block. A module block has exactly one required argument, source — the rest is a version pin, a meta-argument, or an input bound to a variable the child module declared.

Piece of the block Example What it controls
Label module "network" The call's name: builds module.network expressions and state addresses
source "terraform-aws-modules/vpc/aws" Where the child code is fetched from — the only required argument
version "~> 5.5.0" Semantic-version constraint, read for registry sources only
Input arguments cidr = var.vpc_cidr Values bound to variable blocks inside the child
Meta-arguments count, for_each, providers, depends_on How many instances exist and which providers they inherit

The label is not cosmetic. It becomes the address prefix for everything the child creates: module.network.aws_vpc.this[0], module.network.aws_subnet.private[0], and it is the expression handle callers use later — module.network.vpc_id. Rename the label and you rename the entire subtree in state, which means a round of terraform state mv or a moved block to avoid a destroy-and-recreate. The same applies when you move resources into a module for the first time: addresses change, and the lock in State Lock Breakage and Recovery is what keeps two teammates from repairing state at the same moment.

source decides how code is fetched: a local directory, a registry namespace, a Git URL, an archive. Omit it and Terraform refuses to plan:

Error: Missing required argument

  on main.tf line 1, in module "network":
   1: module "network" {

The argument "source" is required, but no definition was found.

version is the part people get wrong in code review. It is a semantic-version constraint (=1.2.0, ~> 1.2.0, >= 1.4, < 2.0) and Terraform applies it only to registry sources. Attach one to a local path and terraform init fails hard instead of quietly ignoring it:

Error: Invalid version constraint

Cannot apply a version constraint to module "network" (at main.tf:1) because
it has a relative local path.

That error is a gift: a silent no-op pin would be worse. A local directory has no version — whatever is committed on the current branch is the version — and Git sources pin through a ?ref= query parameter instead, as Module Source Addresses spells out.

Everything else in the body must match a variable block in the child. Pass something the child never declared and you get a diagnostic aimed at your line:

Error: Unsupported argument

An argument named "enable_flow_logs" is not expected here.

Because arguments are evaluated in the caller's scope, you can compute them freely — cidrsubnet(...), conditionals, for expressions, local.* — and only the resulting values cross the boundary. The child sees values, never the expressions or variables that produced them. That one-way wall is what makes a module reusable across repositories.

The wall cuts both ways, and this is the part people miss. Your root cannot reach into the child and address aws_vpc.this directly — the child's resources are not in your namespace. The only way out is an output block in the child, read as module.network.vpc_id. So a call has two surfaces: inputs in, outputs out, and nothing else. If you find yourself wanting to reference a child resource by its full address from outside, that is the signal you are bypassing the contract the module author defined — worth understanding now, because Module Source Addresses and the input/output contracts after it build the whole discipline out of those two surfaces.

How Each Argument Becomes a var.* Binding Inside the Child Module

Follow cidr = var.vpc_cidr across the boundary and the mechanism is plain: in the child module, cidr is a declared variable, and your argument supplies its value for the lifetime of that call.

# modules/network/variables.tf  (child module)
variable "cidr" {
  description = "IPv4 CIDR block for the VPC."
  type        = string
}

variable "env" {
  description = "Deployment environment name."
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.env)
    error_message = "env must be one of dev, staging, prod."
  }
}

There is no second channel. Input variables, defaults, and validation inside the child — that is the entire contract. terraform.tfvars, -var, -var-file, and TF_VAR_ environment variables all reach the root module's variables only; a child module cannot be configured by them. If a value must differ per environment, the root reads it from its own tfvars and passes it down as an argument. This shows up in interviews as "how does a module get its configuration?", and candidates who answer "tfvars" fail it.

Type conversion happens at the boundary. Terraform converts where the intent is unambiguous — "5" into a number constraint, a list into a tuple — and reports anything it cannot reconcile before any resource is touched:

Error: Invalid default value for module argument

The default value for variable "cidr" is not compatible with its type
constraint: string required.

If the child declares a variable with no default and the caller omits it, plan stops with:

Required variable not set

The variable "cidr" is required, but is not set.

Two details separate people who have shipped modules from people who have only consumed them:

  1. Defaults make the common case free. A module where eight of ten inputs have sensible defaults can be called with zero arguments for a dev environment, and the caller overrides only what matters. A module that demands fourteen arguments on every call has pushed its design work onto every consumer.
  2. Validation runs at plan time, inside the child. When the env check above fails, the output names your module's file and the rule that caught it:
Error: Invalid value for variable

environment must be one of dev, staging, prod.

This was checked by the validation rule at modules/network/variables.tf line 12,
in variable "env":

That message appears long before the provider ever sees the value. Without it, a typo'd environment name sails into an AWS API call mid-apply and fails somewhere three levels deeper, with an error that points at nothing you wrote.

Note where these errors land: variable "env" lives in the child file, but the caller supplied the bad value. That is why the diagnostic prints both the child's declaration and, in the module call section, your root's module "network" block. When you debug a module, read both ends of the binding — the declaration for the rule, the call for the value.

Defaults also change what a call looks like. If the child above had sensible defaults for everything except cidr, the same network could be requested in three lines:

module "scratch" {
  source = "./modules/network"
  cidr   = "10.9.0.0/16"
}

Contrast that with a module that has no defaults: every caller must restate env, name_prefix, enable_flow_logs, and a dozen other values, usually copy-pasted from the previous consumer until three repositories disagree about the defaults. A missing default is a decision the module author deferred to every caller — and each caller will make it differently.

Two Calls, Two Trees: count, for_each, and providers on the Module Block

The same module can be instantiated side by side. A load-balancer module called twice produces two independent trees in state, two sets of outputs, and no shared anything:

module "public_alb" {
  source            = "./modules/load-balancer"
  internal          = false
  subnet_ids        = var.public_subnet_ids
  security_group_id = aws_security_group.alb_public.id
}

module "internal_alb" {
  source            = "./modules/load-balancer"
  internal          = true
  subnet_ids        = var.private_subnet_ids
  security_group_id = aws_security_group.alb_internal.id
}

Outputs stay separate too: module.public_alb.arn and module.internal_alb.arn are distinct values, and destroying one call never touches the other. This is the practical answer to "why not just copy the file?" — reuse without coupling.

When the instances differ only by data, put count or for_each on the module block itself (supported since Terraform 0.13):

module "bucket" {
  source   = "./modules/bucket"
  for_each = var.regions

  region = each.key
  name   = "acme-${var.env}-${each.key}"
}

Each instance gets its own address — module.bucket["us-east-1"].id — and because a single for_each module produces a map of instances, callers read it as a collection: { for k, b in module.bucket : k => b.arn }. With count instead, you get a list and index-based access (module.subnet[0].id), which is why for_each is the default recommendation: keys are stable identities, indexes are positions, and inserting an element in the middle of a list shifts every address after it. Renaming a for_each key has the same hazard — Terraform destroys the instance under the old key and builds a new one. You see it in the plan before anything happens:

  # module.bucket["eu-west-1"] will be destroyed
  # module.bucket["eu-west-1a"] will be created
Choice Address form Best for Failure mode
count module.subnet[0] Few, ordered, identical instances Inserting or reordering shifts every index
for_each module.bucket["eu-west-1"] Instances with meaningful names Renaming a key destroys and recreates that instance
Two labeled calls module.public_alb Two genuinely different instances None — but duplication grows with each variant

The third row is the honest alternative: when two instances differ in more than data — different inputs, different lifecycle rules, different reviewers — two explicit blocks are clearer than a clever map.

The third meta-argument routes providers into the child:

module "replica_database" {
  source = "./modules/database"

  providers = {
    aws = aws.replica
  }
}

Without it the child inherits the caller's default provider configuration only; there is no way for the child to select your aliased aws.replica. The trade-off: a module that declares explicit provider configuration inside itself, or is handed one this way, cannot be combined with count, for_each, or depends_on — Terraform rejects the block during init because instance expansion would multiply a single shared provider configuration in ways it cannot reason about. Push the variability into inputs instead.

depends_on on a module block forces every resource in the child to depend on the listed objects. Reach for it last: it hides real edges from the graph, slows planning, and usually signals that an output should have been wired through instead. The correct replacement is almost always a value that flows from the upstream module's output into the downstream module's input — Terraform then infers the same ordering from data flow, and the plan still explains why each resource waits.

Interviewers like this area because it tests whether you understand instances versus code. Expect: "If I change a for_each key, what happens to real resources?" — answer with the destroy-and-recreate behavior above, then say how you avoid it: stable keys, and moved blocks when an address must change.

Quiz

1. You write `source = "./modules/vpc"` alongside `version = "~> 1.2.0"` in a module block. What does Terraform do?

Question 1 options

2. A caller passes `enable_flow_logs = true` to a child module that declares no such variable. Which error appears?

Question 2 options

3. Two module blocks share the same source but have different labels. What is true after apply?

Question 3 options

4. Where do `terraform.tfvars`, `-var`, and `TF_VAR_environment` apply their values?

Question 4 options

Flashcards

Question

Which argument is required in every module block, and what error names it when absent?

Answer

`source`. Without it Terraform reports `Error: Missing required argument` with the detail `The argument "source" is required, but no definition was found.` It is the only required argument; version, count, for_each, providers, and inputs are all optional.

Question

What does the label of a `module "x"` block determine?

Answer

Two things: the expression handle callers use (`module.x.vpc_id`) and the state address prefix for every resource the child creates (`module.x.aws_vpc.this[0]`). Renaming the label rewrites the whole subtree in state, so it needs `terraform state mv` or a `moved` block.

Question

What is the `version` argument valid for, and what happens on a local or Git source?

Answer

Registry sources only. On a relative local path, init fails with `Invalid version constraint` — "Cannot apply a version constraint ... because it has a relative local path" — and a non-registry remote source fails with "... because it doesn't come from a module registry." Git sources pin with `?ref=<tag-or-sha>`.

Question

Which meta-arguments can appear on a module block, and which one routes provider configurations in?

Answer

`count`, `for_each`, `providers`, and `depends_on`. The `providers` meta-argument maps the child's provider requirements to caller configurations (e.g. `aws = aws.replica`), and a module given explicit providers cannot also use count, for_each, or depends_on.

Question

How do values cross the module boundary, and what does the child never see?

Answer

Arguments in the caller's `module` block are evaluated in the caller's scope and bound to the child's declared `variable` blocks. The child sees only the resulting values — never the expressions, `local` values, or root variables that produced them.

Revision Notes

Key Takeaways

  • 1. A module block has exactly one required argument (`source`); its label becomes both the `module.<label>.*` expression handle and the state address prefix
  • 2. Inputs are bound to `variable` declarations in the child — evaluated in the caller's scope so only plain values cross the boundary
  • 3. `version` pins registry modules only; on a local path Terraform fails init with `Invalid version constraint`, and Git sources pin via `?ref=`
  • 4. Each call is a distinct instance: side-by-side calls of one source share nothing, and `for_each` keys must stay stable or instances are destroyed and recreated
  • 5. Variable files and `TF_VAR_*` environment variables never configure child modules; passing them down as arguments is the root module's job

Interview Tips

  • • "Walk me through a module block." — name label, source, version, inputs, meta-arguments in one breath, then state the two hard rules: source is required, version is registry-only
  • • "What happens when I rename a module label?" — the state subtree address changes; answer with `terraform state mv` or a `moved` block, and say why apply-without-repair would recreate everything
  • • "How does a child module learn its values?" — arguments plus defaults; explicitly rule out tfvars/TF_VAR for children, then mention type conversion and validation happen at the boundary during plan
  • • "Why would you use `for_each` over `count` on a module?" — keys are stable identities, indexes are positions; inserting or reordering a count list shifts addresses and forces replacements

Cheat Sheet

Module Block Anatomy

Anatomy of one call:

module "network" {            # label → module.network.* addresses & expressions
  source  = "terraform-aws-modules/vpc/aws"   # required: how code is fetched
  version = "~> 5.5.0"        # registry sources ONLY (semver constraint)

  cidr = var.vpc_cidr         # input → binds child variable "cidr"

  for_each = var.regions      # meta-arguments: count | for_each | providers | depends_on
}

Error map (what review or plan tells you):

Symptom Error text Meaning
No source Missing required argument The argument "source" is required, but no definition was found.
Unknown input Unsupported argument An argument named "x" is not expected here.
version on local path Invalid version constraint ... because it has a relative local path.
Unset required child var Required variable not set The variable "x" is required, but is not set.
Bad value vs validation Invalid value for variable prints your error_message + This was checked by the validation rule at ...

Instance rules:

  • Two calls of one source = two independent state trees and output sets.
  • for_each keys are identities (stable); count indexes are positions (shifting destroys/recreates).
  • Explicit providers on a call excludes count / for_each / depends_on.
  • terraform.tfvars, -var, TF_VAR_* reach the root module only — children get values via arguments.

Continue Learning