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 plan or terraform plan. It has the state, the backend and usually the provider configuration.
  • A child module is a module that another module calls with a module block. 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:

Module tree. The root module in the folder platform calls the same child module, modules/nginx-upstream, twice: once as module.portal and once as module.meter_api. Each call creates its own local_file.this resource, with its own address in the state. Root module platform/main.tf module.portal 2 servers module.meter_api 1 server source = "./modules/nginx-upstream" (one folder, two copies)
One child module, called twice by the root module. Each call is a separate instance with its own inputs and its own resources.

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 next init -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_providers with 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 with count or for_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.