Modules: Building Reusable Infrastructure Components
Modules: Building Reusable Infrastructure Components
A module is a directory of configuration that can be called with inputs and returns outputs. The root module is the one you apply. Everything else is a child. This chapter is how to draw that line so a module stays reusable without becoming a second programming language.
Learning goals
- Call a local child module and read its outputs
- Decide what belongs inside the module and what stays in the root
- Pin a registry or git source the way you pin a provider
- Recognise a module that hides too much
What Is a Module?: Root Modules, Child Modules, and Published Modules
Every directory you run tofu in is a root module. It has the backend, the provider configuration, and the state. A child module is a directory you refer to with a module block. It has variables, resources, and outputs. It does not have its own backend. Its resources live in the root’s state, under addresses like module.pets.random_pet.service.
A published module is a child someone else versions. The OpenTofu registry, a git tag, or an OCI artifact are sources. Publishing does not change the language. It changes who is allowed to move the inputs under you.
One root module per state remains the rule. A child that tries to configure a backend is ignored or rejected. If two parts of the system must change on different schedules or different permissions, they are two roots, not two children.
Designing Your First Module: Inputs, Outputs, and Responsibility Boundaries
A module does one thing a caller can name: “a pet name with a prefix”, “a bucket with versioning and a policy”, “a network with these subnets”. If the name needs “and”, it is probably two modules.
modules/pets/
├── versions.tf
├── variables.tf
├── main.tf
└── outputs.tf
# modules/pets/variables.tf
variable "pet_length" {
type = number
default = 2
}
# modules/pets/main.tf
resource "random_pet" "service" {
length = var.pet_length
}
# modules/pets/outputs.tf
output "name" {
value = random_pet.service.id
}
The boundary test: a caller who does not care how you implemented it should still be able to use it. If the caller must know you used random_pet, the output is leaking the implementation and the module is a folder pretending to be an API. Export the name. Keep the resource type inside.
Do not put provider credentials in a child. The root configures providers and passes aliases. Do not put environment names inside the module as a switch (if var.env == "prod"). Pass the values that differ. A module that contains a production special case is a root module that cannot be reused.
Calling Modules: Local Paths, Registry Sources, and Git Repositories
module "pets" {
source = "./modules/pets"
pet_length = var.pet_length
}
module "pets_remote" {
source = "example.com/team/pets/generic"
version = "1.4.2"
}
A local path (./modules/pets) is the right source while the module and the root change together. There is no separate version. The git commit is the version.
A registry source has a version constraint. Pin it the way you pin providers. version = "~> 1.4" accepts patch releases. Read them. An unbounded version is a supply-chain surprise.
A git source pins a ref:
module "pets_git" {
source = "git::https://example.invalid/team/pets.git?ref=v1.4.2"
}
Pin a tag or a commit. Pinning a branch tracks whatever the branch points at the next time someone deletes .terraform/modules and inits. That is not reproducibility. Private git needs the pipeline’s credentials, not a token in the source URL.
OpenTofu 1.8 can use variables in module source in more cases than Terraform 1.5 could. A source that changes with a variable changes which code you are about to apply. Keep sources static in root modules unless you have a reviewed reason.
The OpenTofu Registry: Publishing and Consuming Community Modules
registry.opentofu.org serves provider and module metadata. Consuming a module is the source and version above, then tofu init. The registry records who published the module. That namespace is the identity you are trusting, the same way a provider namespace is.
Publishing is a review, not an upload button hidden in the CLI. The registry project accepts modules through its documented submission process. A module you publish is a contract: input types, outputs, and the objects you create. A breaking change is a major version. Yanking a version that someone has pinned breaks their init. Do not republish a tag with different code.
Prefer a small number of modules your team owns over a deep tree of community modules you have not read. A community module that creates IAM is a module you read line by line before the first apply. Fork and pin if you depend on it and you cannot accept surprise commits.
Advanced Module Patterns: Stacks, Composition, and Versioned Releases
Composition means a root module calls a few child modules and passes outputs across. That is the pattern to keep.
module "name" {
source = "./modules/pets"
pet_length = 2
}
module "record" {
source = "./modules/record"
name = module.name.name
}
A stack, in the sense that matters here, is that root plus its state plus its pipeline. It is not a new language. HashiCorp’s Terraform Stacks product is a separate commercial workflow. You do not need it to compose OpenTofu modules.
Versioned releases apply to modules other teams call. Tag semver. A changelog says whether inputs were added (compatible) or renamed (breaking). Internal modules used by one root can live in the same repository and move without tags until a second caller appears. The day a second caller appears, the module gets a version or it gets copied. A floating local path shared by two roots via a relative ../../ is how unrelated applies break together.
for_each on a module creates one instance per key, each with the same child code and different inputs. The keys are stable names. This is the right way to stamp out one bucket per application. It is the wrong way to hide a loop the reader cannot see without expanding state.
Summary
- The root owns the backend and the state. Children own a reusable graph
- Inputs and outputs are the API. Resource addresses are not
- Pin registry versions and git refs. A branch is not a pin
- Compose a few modules at the root. Do not build a framework
- A module that looks up its own dependencies hides the thing the plan should show