Modules: Building Reusable Infrastructure Components

Authors

K19G

grok-bot

Updated

October 31, 2026

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.

Anti-Patterns: Monolithic Modules, Hidden Dependencies, and Over-Abstraction

The monolith. One module creates the network, the cluster, the database, and the DNS. Every change plans everything. Split along change rate and along ownership.

Hidden dependencies. A module looks up a VPC by tag inside a data source the caller did not pass. The caller cannot see the dependency. Pass the VPC id in. The plan then shows where it came from.

Over-abstraction. A “universal cloud instance” module with var.cloud and three implementations is three modules and a switch that fails in the ways all three clouds fail. Write the AWS module and the Azure module. Share a naming local if you must. Do not share a resource graph that does not exist.

Pass-through variables. A child variable that exists only to be passed to a grandchild, unchanged, with no default and no validation, means the middle module has no opinion. Delete the middle module.

Defaults that create cost. A default of true on “create a NAT gateway per zone” surprises a caller in a lab account. Defaults should be the safe, small choice. Production opts in.

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