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:
- variables, outputs and locals (12.5)
for_eachand theforexpression (12.20, 12.11)- provider version constraints like
~> 4.0, and provideralias(12.3, 12.4) depends_onand how references order the graph (12.18)- git tags, branches and commits (1.17, 1.19)
A module is a directory
Every directory of .tf files is a module. You have been writing modules all along without the name:
- the root module is the directory you run
terraformin; - a child module is another directory that the root module calls.
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:
sourcesays where the module's code is (here a local folder).- The label (
"network") is the module's local name. It becomes part of every address inside:module.network.azurerm_subnet.this["app"]. - Every other argument sets one of the module's variables - except the meta-arguments, which Terraform itself handles:
source,version,count,for_each,providers,depends_on. module.network.subnet_idsreads one of the module's outputs.
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.
- The root's
var.envdoes not exist insidemodules/network. - The root cannot reach
module.network's resources or locals - only its outputs.
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 }
}
- A module output that exposes a sensitive value must say
sensitive = true, and it stays sensitive in the caller. - Output whole objects sparingly (
value = azurerm_subnet.this). That exposes every internal attribute to callers, and then any change inside the module can break them.
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
- Local paths start with
./or../and are read straight from disk. Edits take effect on the next plan; no init needed after the first. - Registry addresses are
<NAMESPACE>/<NAME>/<PROVIDER>. The module registry is the same Terraform Registry that serves providers (12.3), or a company's private one (then a hostname goes in front). Registry sources are the only ones that take aversionargument.Azure/avm-res-...is one of Microsoft's Azure Verified Modules (AVM), its official, maintained modules. - Git sources:
//networkpicks a directory inside the repository;?ref=picks a tag, branch or commit. For git, the ref is the version. - Everything that is not local is downloaded by
initinto.terraform/modules/, and recorded in.terraform/modules/modules.json. Change a source or a ref and you must runinitagain:
│ 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.
initpicks the newest version that matches the constraint and records it inmodules.json. A laterinitkeeps it;init -upgrademoves to the newest match again.- Tighten the constraint so the installed version no longer matches, and plan refuses until you re-init:
│ 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.
- Ask for a version that does not exist:
│ 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.
versionon a local or git source is an error - those have no versions.- Module versions are not in
.terraform.lock.hcl. The lock file only covers providers. With a loose module constraint, two engineers can get different module versions. Pin exact versions (version = "0.4.2") for anything that matters, or a tag / commit for git.
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:
- write a module call, pass inputs in and read outputs out
- choose a module source and pin its version, tag or commit
- use
for_eachandproviderson a module call