Modules and Reuse
Packaging configuration into modules: inputs and outputs, local and registry modules, versioning and pinning, and how to keep modules small and useful.
Every folder is a module
The
Cloud topic
shows the idea of a
Terraform module: a folder
of configuration used as a building block. Terraform and OpenTofu
treat every folder of .tf files as a module, whether or
not anyone reuses it.
-
The
root module is the folder in
which you run
tofu planorterraform plan. It has the state, the backend and usually the provider configuration. -
A child module is a module
that another module calls with a
moduleblock. It receives values through its input variables and hands values back through its outputs; it cannot see the caller's variables or resources otherwise.
This is the same deal as a function in a programming language (compare a Python module): inputs in, results out, internals hidden. Ravi Kumar at Kestrel Energy writes the nginx upstream block for both the customer portal and the meter-api. Instead of two nearly identical copies, he writes one module and calls it twice:
Writing a small module
The module lives in its own folder under modules/. The
usual split is one file each for inputs, resources and outputs:
$ cd /home/amara/kestrel-infra/platform
$ find . -type f -not -path "./.terraform/*" | sort
./main.tf ./modules/nginx-upstream/main.tf ./modules/nginx-upstream/outputs.tf ./modules/nginx-upstream/upstream.conf.tftpl ./modules/nginx-upstream/variables.tf ./.terraform.lock.hcl
modules/nginx-upstream/variables.tf — the module's
inputs, each with a description and a type, and a validation rule
that catches an empty server list before anything is built:
variable "name" {
description = "Name of the upstream block, e.g. kestrel_portal."
type = string
}
variable "servers" {
description = "Backend addresses as host:port."
type = list(string)
validation {
condition = length(var.servers) > 0
error_message = "At least one backend server is required."
}
}
variable "output_dir" {
description = "Directory to write the rendered file to."
type = string
}
modules/nginx-upstream/main.tf — the resource. The
module states which provider it needs with a loose lower bound
(>= 2.4) and leaves the exact choice to the root
module. Inside a module, resources often get the name
this, because the module itself already says what they
are.
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = ">= 2.4"
}
}
}
resource "local_file" "this" {
filename = "${var.output_dir}/${var.name}.conf"
content = templatefile("${path.module}/upstream.conf.tftpl", {
name = var.name
servers = var.servers
})
}
path.module is the folder of the module itself, so the
template is found wherever the module is called from.
modules/nginx-upstream/upstream.conf.tftpl uses the
template syntax described in
Configuration Languages: HCL, YAML and JSON:
upstream ${name} {
%{ for s in servers ~}
server ${s};
%{ endfor ~}
}
modules/nginx-upstream/outputs.tf — what the caller may
use:
output "path" {
description = "Path of the rendered upstream file."
value = local_file.this.filename
}
Calling a module
The root module's main.tf calls the module twice. The
label after module (portal,
meter_api) names the instance; source says
where the code is; the other arguments are the module's input
variables. The root module pins the provider more tightly (~> 2.5) than the module does.
platform/main.tf
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
module "portal" {
source = "./modules/nginx-upstream"
name = "kestrel_portal"
servers = ["192.0.2.11:8080", "192.0.2.12:8080"]
output_dir = "${path.root}/out"
}
module "meter_api" {
source = "./modules/nginx-upstream"
name = "kestrel_meter_api"
servers = ["192.0.2.21:8443"]
output_dir = "${path.root}/out"
}
output "upstream_files" {
value = [module.portal.path, module.meter_api.path]
}
module.portal.path reads the path output
of that instance. A new module call needs
tofu init first, because init installs
modules as well as providers. On a fresh clone even
plan refuses to start until then:
$ tofu init
Initializing the backend... Initializing modules... - meter_api in modules/nginx-upstream - portal in modules/nginx-upstream Initializing provider plugins... - Reusing previous version of hashicorp/local from the dependency lock file - Detected previously-installed hashicorp/local v2.9.1 in the shared cache directory - Using hashicorp/local v2.9.1 from the shared cache directory OpenTofu has been successfully initialized! …
Both calls point to the same folder. The apply then creates one file per instance (the long list of hash attributes is shortened here):
$ tofu apply -auto-approve
OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
OpenTofu will perform the following actions:
# module.meter_api.local_file.this will be created
+ resource "local_file" "this" {
+ content = <<-EOT
upstream kestrel_meter_api {
server 192.0.2.21:8443;
}
EOT
…
+ filename = "./out/kestrel_meter_api.conf"
+ id = (known after apply)
}
# module.portal.local_file.this will be created
+ resource "local_file" "this" {
+ content = <<-EOT
upstream kestrel_portal {
server 192.0.2.11:8080;
server 192.0.2.12:8080;
}
EOT
…
+ filename = "./out/kestrel_portal.conf"
+ id = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ upstream_files = [
+ "./out/kestrel_portal.conf",
+ "./out/kestrel_meter_api.conf",
]
module.meter_api.local_file.this: Creating...
module.portal.local_file.this: Creating...
module.meter_api.local_file.this: Creation complete after 0s [id=02665ac5e33c6e4c06c225328b0148683efc7cf3]
module.portal.local_file.this: Creation complete after 0s [id=58c9908a7a25b9e708b9b8b1de1a3cf6503569a0]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
…
$ cat out/kestrel_portal.conf
$ tofu state list
upstream kestrel_portal {
server 192.0.2.11:8080;
server 192.0.2.12:8080;
}
module.meter_api.local_file.this
module.portal.local_file.this
Resources inside a module get the prefix
module.<name>. in their address. That matters
later: moving an existing resource into a module changes its
address, and without a moved block the tool would
destroy and re-create it (see
renaming with a moved block).
Two errors you will meet
Ravi adds a third call, module "billing", with
source = "./modules/billing-cron", and runs a plan
without init. Later, a colleague passes an empty server
list to the meter-api call:
$ tofu plan
╷
│ Error: Module not installed
│
│ on main.tf line 28:
│ 28: module "billing" {
│
│ This module is not yet installed. Run "tofu init" to install all modules
│ required by this configuration.
╵
╷ │ Error: Invalid value for variable │ │ on main.tf line 20, in module "meter_api": │ 20: servers = [] │ ├──────────────── │ │ var.servers is empty list of string │ │ At least one backend server is required. │ │ This was checked by the validation rule at │ modules/nginx-upstream/variables.tf:10,3-13. ╵
The second error is the module doing its job: the validation rule lives in the module, but the error points at the caller's line, so the person who made the mistake sees where to fix it. Validation in modules is cheap documentation that enforces itself.
Where modules come from
The module source is the
source argument of a module block: the
address from which init fetches the module's code. The
main kinds:
| Kind | Example source | How it is pinned |
|---|---|---|
| Local path | ./modules/nginx-upstream |
Same repository, same commit as the caller |
| Module registry |
registry.example.com/kestrel/nginx-upstream/local
|
The version argument |
| Git repository |
git::https://git.example.com/kestrel/tofu-modules.git//nginx-upstream?ref=v1.2.0
|
?ref= a tag or commit |
| OCI registry (OpenTofu 1.10+) |
oci://registry.example.com/kestrel/nginx-upstream?tag=1.2.0
|
?tag= or a digest |
| Archives and buckets | An HTTPS URL to a zip file, S3 or GCS objects | The file name or object version |
Local paths need no version, because the module changes together
with the code that calls it. That is ideal inside one repository and
awkward across teams: every repository then carries its own copy,
and copies drift apart. Once several repositories use a module,
publish it with versions — as Git tags, in a registry or in an OCI
registry — and let each caller choose when to upgrade. The double
slash (//) in the Git address separates the repository
from a folder inside it. Illustrative calls:
module "portal" {
source = "git::https://git.example.com/kestrel/tofu-modules.git//nginx-upstream?ref=v1.2.0"
name = "kestrel_portal"
servers = ["192.0.2.11:8080", "192.0.2.12:8080"]
output_dir = "${path.root}/out"
}
module "meter_api" {
source = "registry.example.com/kestrel/nginx-upstream/local"
version = "~> 1.2.0"
name = "kestrel_meter_api"
servers = ["192.0.2.21:8443"]
output_dir = "${path.root}/out"
}
The version argument works with registry sources. For
Git and OCI sources the tag or commit inside the address is the pin.
Changing it means running tofu init again; for registry
modules, tofu init -upgrade picks the newest version
the constraint allows.
Version constraints
A
version constraint is a
string that says which versions of a module, a provider or the tool
itself are acceptable, such as
">= 1.2.0, < 2.0.0". The operators are
=, !=, >,
>=, <, <= and
~>; a comma means "and". Versions follow
semantic versioning: major.minor.patch, where a new major version may break callers.
The
pessimistic constraint operator
~> allows only the rightmost version part you wrote
to increase. ~> 1.2 means "1.2 or newer, but below
2.0"; ~> 1.2.0 means "1.2.0 or newer, but below
1.3.0". It gives a lower and an upper bound in one short string,
which is why the OpenTofu documentation recommends it for root
modules. This small program applies the rules to five released
versions and picks the newest allowed one, as init
does:
versions = ["1.1.4", "1.2.0", "1.2.7", "1.3.1", "2.0.0"]
constraints = ["~> 1.2", "~> 1.2.0", ">= 1.2.0, < 1.3.0", "= 1.2.0"]
def parse(text):
"""'1.2' -> (1, 2, 0), so versions of different lengths compare."""
parts = [int(p) for p in text.split(".")]
return tuple(parts + [0] * (3 - len(parts)))
def check(version, op, wanted):
v = parse(version)
if op == "~>":
# Only the rightmost given part may grow: ~> 1.2 means >= 1.2, < 2.0
upper = [int(p) for p in wanted.split(".")][:-1]
upper[-1] += 1
return parse(wanted) <= v < parse(".".join(map(str, upper)))
w = parse(wanted)
return {"=": v == w, "!=": v != w, ">": v > w,
">=": v >= w, "<": v < w, "<=": v <= w}[op]
def allowed(version, constraint):
"""A constraint is a comma-separated list; every part must hold."""
return all(check(version, *part.split()) for part in constraint.split(","))
for constraint in constraints:
ok = [v for v in versions if allowed(v, constraint)]
print(f"{constraint:<19} allows {', '.join(ok):<20} picks {ok[-1]}")
~> 1.2 allows 1.2.0, 1.2.7, 1.3.1 picks 1.3.1 ~> 1.2.0 allows 1.2.0, 1.2.7 picks 1.2.7 >= 1.2.0, < 1.3.0 allows 1.2.0, 1.2.7 picks 1.2.7 = 1.2.0 allows 1.2.0 picks 1.2.0
The real implementation also handles pre-release versions such as
1.3.0-beta1, which a range never selects unless you
name them exactly. Choosing a constraint is a trade-off:
-
Exact (
= 1.2.0): nothing changes until you change it. Upgrades are deliberate pull requests, which suits production root modules and third-party code. -
Patch range (
~> 1.2.0): bug fixes arrive with the nextinit -upgrade. -
Minor range (
~> 1.2): new features too, still no new major version. -
Lower bound only (
>= 2.4): right for reusable modules stating what they need from a provider, wrong for root modules.
Whatever the constraint, the module's author has to keep the promise of semantic versioning: renaming an input variable or removing an output is a breaking change and needs a new major version. Tools such as Dependabot can open pull requests when a pinned module or provider has a new release, so exact pins do not quietly go stale.
Registries and trust
A module registry is a
service that publishes versioned modules under addresses of the form
namespace/name/provider, so that init can
list the versions and download the one a constraint selects. The
public ones are the OpenTofu Registry
(registry.opentofu.org, searchable at
search.opentofu.org) and the Terraform Registry
(registry.terraform.io); a short address such as
namespace/name/provider goes to the public registry of
the tool you run. Private registries put their hostname in front, as
in the examples above. Organizations also publish modules as Git
tags, in an OCI registry they already run for container images
(OpenTofu), or in HCP Terraform's private registry.
A public module is code from a stranger that runs with your cloud credentials and can create, change or delete anything those credentials allow. Treat it like any other dependency in your software supply chain:
- Read it before use, and read the diff before each upgrade. Look especially for provisioners, external data sources and anything that downloads or runs scripts.
- Prefer well-maintained modules with many users and a clear owner, or write a small one yourself.
- Pin exactly (a version, or a commit hash for Git), and consider mirroring approved modules into your own registry or repository, so a deleted or replaced upstream release cannot break or compromise your builds.
A supply chain attack through a popular module or a hijacked tag would reach every organization that pulls "latest". Pinning and review are what keep yours out of that list.
Designing useful modules
A module is worth having when it hides real complexity behind a simple interface, or when it enforces a rule everyone should follow — every web host gets the same logging setup, every storage bucket is private and versioned. Some guidelines:
- One purpose. "nginx upstream", "web host", "PostgreSQL server with backups". A module named after a team or an environment ("kestrel-prod") is usually a whole configuration in disguise.
- Few, typed, validated inputs with descriptions and sensible defaults. A module with eighty variables is harder to use than the resources it wraps.
- Outputs for what callers need: IDs, addresses, file paths — not every attribute.
-
No provider configuration inside. Reusable
modules declare
required_providerswith a lower bound; the root module configures providers (region, credentials) and passes them down. A module that configures its own provider is hard to remove and cannot be used withcountorfor_each. - Do not wrap a single resource just to have a module: it adds a layer of variables and nothing else.
- A README and an example folder that shows a working call, plus tests (see Testing and Policy as Code).
- Nest shallowly. A root module that calls a few modules is easy to follow; modules calling modules calling modules are not.