How to wrap infrastructure into reusable modules with the main.tf, variables.tf, and outputs.tf structure, then consume them from the OpenTofu Registry, Git repositories, or a local path to build a consistent platform across many teams and environments.

In the previous episode 9 we covered state manipulation & import workflows — importing existing infrastructure via the import block and tofu import, then refactoring without destroy using the moved block and tofu state. Your infrastructure is now neatly recorded in state and safely manageable. The next question emerges: how do we stop every team from writing the same code from scratch?
Without modules, five teams in your company will each declare VPCs, security groups, and databases in slightly different ways. One team opens SSH to the public, another forgets to enable encryption, and worst of all — everything looks "working" so nobody notices the inconsistency until an incident happens.
In this episode we cover reusable modules & the OpenTofu Registry: how to wrap infrastructure into reusable modules, the standard main.tf, variables.tf, and outputs.tf file structure, and how to consume modules from the OpenTofu Registry, Git repositories, or a local path.
A module is a collection of OpenTofu resources packaged as one unit with inputs (variables) and outputs (exposed values). The best analogy is LEGO blocks: you don't assemble every gear yourself; you use ready-made blocks. All the "correct defaults" are encapsulated inside the module, and consumers only need to supply small inputs.
Every configuration we've written so far — including modules themselves — is actually a root module. The difference: reusable modules are called many times via the module block, while a root module is unique and is the execution entry point.
The standard convention for a module consists of three files: main.tf for resources, variables.tf for inputs, and outputs.tf for results:
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
tags = var.tags
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnets[count.index]
tags = {
Name = "public-${count.index}"
}
}Notice the pattern: variables.tf defines the input contract with type, description, and default; main.tf consumes it via var.name references; outputs.tf exposes the values callers need. A good module only exposes the outputs that are truly needed, not every internal attribute.
Tip
Always fill in description on every variable and output — this isn't secondary documentation, it's part of the module's interface. Module consumers will see these descriptions when running tofu docs or using module tooling. After calling a module, don't forget to run tofu init to download its dependencies.
The OpenTofu Registry (registry.opentofu.org) is the public home for modules and providers. Popular community modules like terraform-aws-modules/vpc/aws can be called directly with the shorthand namespace/name/provider:
module "vpc" {
source = "registry.opentofu.org/terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "production"
cidr = "10.0.0.0/16"
azs = ["ap-southeast-1a", "ap-southeast-1b"]
}
module "vpc_staging" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "staging"
cidr = "10.1.0.0/16"
azs = ["ap-southeast-1a"]
}Two things to note. First, source can be written in full with the registry host, or shortened — the registry.opentofu.org host is used by default. Second, calling the same module twice is fine: each module block produces a separate instantiation, each with its own inputs. That's the power of "one definition, many instances."
Warning
Always pin version when using a module from the registry. Without a version, one upstream module update could unexpectedly change your entire infrastructure on the next tofu init. For production environments, combine with a lock file to pin provider and module versions.
Not every module deserves publication to the registry. OpenTofu supports three main sources:
| Source | Example source | When to Use |
|---|---|---|
| OpenTofu Registry | terraform-aws-modules/vpc/aws | Public modules, managed versions |
| Git repository | git::https://github.com/org/modules.git//vpc?ref=v1.0.0 | Private team modules, review workflows |
| Local path | ./modules/vpc | Internal modules in a single repository |
The Git source uses the git::URL//subdir?ref=ref scheme — the double slash points to a directory inside the repo, and ref pins to a specific branch, tag, or commit:
module "vpc" {
source = "git::https://github.com/org/modules.git//vpc?ref=v1.0.0"
cidr = "10.0.0.0/16"
subnets = ["10.0.1.0/24"]
}A local path is best when the module is still evolving in the same repository as its caller — no need to push to the registry for every change:
module "vpc" {
source = "./modules/vpc"
cidr = "10.2.0.0/16"
subnets = ["10.2.1.0/24"]
}Note
Moving from a local path to Git usually only changes source — the rest stays. A common development pattern: develop on a local path, push to Git once stable, then pin ref to a release tag in the root module. This gives full control without sacrificing iteration speed.
description; provide a README when needed.tofu test framework we'll cover next episode.In episode 10 we built the foundation of modularity:
main.tf, variables.tf, and outputs.tf forms a clear module contract.source and pinned version.With modules, you can build a consistent platform across many teams and environments. In the next episode, episode 11, we'll cover Formatting, Linting & the Native Testing Framework — tidying code with tofu fmt, validating with tofu validate, scanning with Checkov and Trivy, then writing .tftest.hcl tests with tofu test without needing Terratest. See you there!