OnCallReady

Lesson 13.34 · Terraform: State & Modules · 32 min read

Modules: layout, inputs, sources and refactoring into them

In plain words

A module is like a LEGO instruction booklet for one part: "how to build a standard garage". You give it a few choices (colour, size), it builds the garage the same way every time, and it hands back a few facts (where the door is). You can't reach inside a finished garage from outside; you only get the facts it chose to give you.

In Terraform, every directory of .tf files is a module. The root module calls child modules with module "network" { source = "./modules/network" ... }; arguments set the child's variables, and module.network.subnet_ids reads its outputs. Nothing leaks either way. source can be a local path, a registry address with a version, or a git URL with ?ref=. Pin those refs, or the booklet changes under you.

The problem

Three teams each need a VNet with subnets. Each copies the same 80 lines of Terraform and tweaks them. A year later there are three slightly different copies, a security fix has to be made three times, and nobody knows which copy is "right".

A module is Terraform's answer: write the VNet once, as a reusable unit with inputs and outputs, and call it from every place that needs one - like a function in JavaScript.

What you need to know already:

A module is a directory

Every directory of .tf files is a module. You have been writing modules all along without the name:

A typical child module looks like this:

modules/network/
  main.tf         resources
  variables.tf    the inputs callers can set
  outputs.tf      the values callers can use
  versions.tf     required_providers with minimum versions - no provider blocks
  README.md       how to call it
  examples/       a small working caller

You call it with a module block:

module "network" {
  source              = "./modules/network"
  name                = "vnet-orders-dev"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  address_space       = ["10.20.0.0/16"]
  subnets             = { app = "10.20.1.0/24", data = "10.20.2.0/24" }
}

output "subnet_ids" {
  value = module.network.subnet_ids
}

Reading it:

A new or changed module call needs terraform init before plan works (init installs modules; terraform get does only that part).

Variable scope: modules are sealed

A module sees only its own variables, locals and resources. There are no global variables.

root                                    modules/network
  var.env          ── not visible ──>     (must declare its own variable "env")
  module.network.subnet_ids   <──── output "subnet_ids" { value = ... }
  module.network.azurerm_subnet.this    ✗ not accessible

So you pass in what the module needs, explicitly:

module "network" {
  source = "./modules/network"
  env    = var.env          # the module declares variable "env"
  tags   = local.tags       # and variable "tags"
}

Plan catches mistakes in both directions. An argument the module has no variable for:

│ Error: Unsupported argument
│
│   on main.tf line 9, in module "network":
│    9:   region  = "westeurope"
│
│ An argument named "region" is not expected here.

A required variable (one with no default) that you did not set:

│ Error: Missing required argument
│
│   on main.tf line 3, in module "network":
│    3: module "network" {
│
│ The argument "name" is required, but no definition was found.

Reading an output the module does not have:

│ Error: Unsupported attribute
│
│   on main.tf line 21, in output "vnet":
│   21:   value = module.network.vnet
│
│ This object does not have an attribute named "vnet".

A caller only sees what the module chooses to output. To make a child module's value visible outside the root (to a pipeline, or to terraform_remote_state, 13.30), re-export it: write a root output whose value is the module output.

Outputs are the module's return values

# modules/network/outputs.tf
output "vnet_id" {
  description = "ID of the virtual network."
  value       = azurerm_virtual_network.this.id
}

output "subnet_ids" {
  description = "Map of subnet name to subnet ID."
  value       = { for k, s in azurerm_subnet.this : k => s.id }
}

Module sources

source can point at many kinds of places:

source = "./modules/network"                                   # local path
source = "../shared/network"                                   # local, outside this dir
source = "Azure/avm-res-network-virtualnetwork/azurerm"        # public registry
source = "app.terraform.io/acme/network/azurerm"               # private registry (HCP)
source = "github.com/acme/tf-modules//network?ref=v1.2.0"      # GitHub shorthand
source = "git::https://github.com/acme/tf-modules.git//network?ref=v1.2.0"   # any git
source = "git::ssh://[email protected]/acme/tf-modules.git//network?ref=5f3e2a1"
source = "https://example.com/modules/network.zip"            # an archive over HTTP
│ Error: Module source has changed
│
│ The source address was changed since this module was installed. Run
│ "terraform init" to install all modules required by this configuration.

(The lab has no internet: git sources come from a copy at /srv/git-mirror/ and registry modules from /srv/tf-registry/ - simulator.)

Module versions

For registry modules, version takes the same constraint syntax as providers (12.3):

module "vnet" {
  source  = "Azure/avm-res-network-virtualnetwork/azurerm"
  version = "~> 0.4"
  # ...
}
Initializing modules...
Downloading registry.terraform.io/Azure/avm-res-network-virtualnetwork/azurerm 0.4.2 for vnet...
- vnet in .terraform/modules/vnet

The init output names the exact version it downloaded (0.4.2) and where it put it.

│ Error: Module version requirements have changed
│
│ The version requirements have changed since this module was installed and the
│ installed version (0.4.2) is no longer acceptable. Run "terraform init"
│ to install all modules required by this configuration.
│ Error: Unresolvable module version constraint
│
│ There is no available version of module "registry.terraform.io/..." which
│ matches the given version constraint. The newest available version is 0.4.2.

Semantic versioning (MAJOR.MINOR.PATCH) is the promise behind the numbers: patch = fixes, minor = new optional features, major = breaking changes (renamed inputs, changed addresses). ~> 1.2 accepts new minors and patches and never a new major.

Branch vs tag vs commit

For git sources the ref decides how stable you are:

?ref=main        a branch - moves every time someone merges; your plan changes with no code change
?ref=v1.2.0      a tag - stable unless someone moves the tag (rare, but possible)
?ref=5f3e2a1     a commit SHA (the commit's ID) - can never change

A branch ref (or no ref at all, which means the default branch) is the classic "nobody changed anything and the plan wants to replace the subnets" incident (13.40). CI runs init fresh on every run, downloads whatever is on main today, and a module change merged by another team lands in your plan.

Pin to a tag or a commit. Upgrade on purpose: bump the ref in a PR and read the plan.

count and for_each on modules

A module call can use for_each like a resource:

module "spoke" {
  for_each = var.spokes                 # { orders = "10.41.0.0/16", payments = "10.42.0.0/16" }
  source   = "./modules/network"
  name     = "vnet-spoke-${each.key}"
  address_space = [each.value]
  # ...
}

output "spoke_ids" {
  value = { for k, m in module.spoke : k => m.vnet_id }
}

Addresses become module.spoke["orders"].azurerm_virtual_network.this. The same rules as for resources apply (12.20): for_each for anything with an identity, keys known at plan time. A module used with count / for_each must not contain provider blocks (next section).

Providers and modules

Modules inherit the default provider configurations of their caller. A module should only declare which providers (and minimum versions) it needs:

# modules/network/versions.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">= 4.0"
    }
  }
}

To hand a module a different provider configuration - an aliased one for a second region or subscription (12.4) - use the providers meta-argument:

module "dr_network" {
  source    = "./modules/network"
  providers = {
    azurerm = azurerm.dr
  }
}

Read it as "inside this module, azurerm means my azurerm.dr". (DR, disaster recovery, is the usual name for a copy in a second region.)

A provider block inside a child module is an old pattern to avoid. It makes the module impossible to use with count / for_each / depends_on. And removing the module later fails: its provider configuration disappears together with the resources that need it to be destroyed.

depends_on on a module

module "app" {
  source     = "./modules/app"
  depends_on = [module.network]
}

It makes everything in module.app wait for everything in module.network. It also delays every data source inside module.app until apply, so plans fill with (known after apply).

Pass the specific values instead (subnet_id = module.network.subnet_ids["app"]) and the ordering follows the reference (12.18). Use depends_on on a module only for a dependency Terraform genuinely cannot see.

What you can now do:

Why it helps

The incident this lesson prevents: "nobody changed anything and the plan wants to replace the subnets". A module sourced from ?ref=main pulled another team's change into your plan on a fresh CI init. Pinning to a tag or SHA, and remembering that the lock file does not cover modules, is what you will enforce in review. You will also debug "Unsupported attribute" (the module has no such output, re-export it), "Module source has changed, run terraform init", and module depends_on turning every data source into (known after apply). Module sources, versions and scope are exam objectives, and modules are how a platform team offers golden paths.

FAQ

Can a module see the root's variables?

No. A module sees only its own variables, locals and resources; there are no globals. If modules/network needs env, it must declare variable "env" and the caller passes env = var.env. Equally, the root sees only the module's outputs, never its resources or locals. To surface a child module's value from the root, re-export it with a root output.

Does .terraform.lock.hcl pin module versions?

No. The lock file only covers providers. Registry module versions are chosen by version constraints and recorded in .terraform/modules/modules.json, which is not committed; git modules are pinned only by ?ref=. So pin exact versions or tags for anything important, and bump them deliberately in a PR where the plan shows the effect.

Why can I not use version with a git source?

The version argument only works for registry sources, which publish versions through the registry protocol. For git, the ref is the version: ?ref=v1.2.0 for a tag or ?ref=5f3e2a1 for an immutable commit. A branch ref, or no ref, means whatever is on the branch at init time, which changes silently.

When do I need to re-run init for modules?

After adding a module call, changing its source, or changing its version or ref. Other commands tell you: "Module not installed" or "Module source has changed". Local path modules are read straight from disk, so edits inside them take effect on the next plan without re-running init once they are installed.

Should a module contain a provider block?

No. Modules should declare required_providers with a minimum version and inherit provider configurations from the caller. A provider block inside a child module prevents using it with count, for_each or depends_on, and removing the module later fails because its provider configuration disappears along with the resources that need it to be destroyed. Pass aliased providers with providers = { azurerm = azurerm.dr }.

In an interview Junior

What is a Terraform module and why would you use one?

A module is a directory of .tf files used as a reusable unit with inputs and outputs - like a function. The directory you run Terraform in is the root module; it calls child modules with a module block:

module "network" {
  source        = "./modules/network"
  address_space = ["10.40.0.0/16"]
}

Why: write it once and every team gets the same, consistent, reviewed resources; a fix is made in one place. Pin versions, because module versions are not in the lock file.

Also asked: What module sources can you use, and how do you version them? · Why is depends_on on a module usually a bad idea? · How do you pass an aliased provider into a module?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.