Module Development and Publishing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A root module is a configuration somebody runs; a reusable module is a configuration somebody else calls. Writing the second kind well is a different craft from writing the first: the author no longer controls the inputs, cannot see the caller’s provider settings, and cannot change a published interface without breaking people. This page covers that craft for Terraform 1.17 and OpenTofu 1.13: the standard module structure, how to design the interface and compose small modules into a system, how to validate inputs and model optional settings with optional(), how to document with terraform-docs, how to start from a template, how to version with SemVer, and how to publish to the public registry, to a private one, or just through Git. Along the way it builds the three modules of the Bookshelf scenario — network, database and service — and composes them into the envs/dev root module.

It follows Modules, which explains how to call a module (the module block, source types, version, meta-arguments), and builds on Variables, Outputs and Locals, Types and Expressions and Providers. It is followed by Refactoring: moved and removed (changing a module without destroying anything), Testing and Code Quality and Security Scanning.

All examples run locally with the kreuzwerker/docker and hashicorp/random providers, so a Docker daemon is the only prerequisite and no cloud account is needed. The module shapes carry over unchanged to cloud resources: replace the Docker resources with the VPC, database and compute resources of your provider.

When a module earns its place

HashiCorp’s guidance on creating modules is deliberately sceptical. A module is "a container for multiple resources that are used together", and its value is that it lets callers describe infrastructure in terms of an architectural concept (a database with its volume and credentials) instead of individual objects. Almost any set of resources can be wrapped, but the documentation recommends moderation: overusing modules makes a configuration harder to understand and maintain.

Two rules of thumb follow from the documentation:

  • Add an abstraction or do not write the module. A module that wraps exactly one resource type and re-exports all of its arguments adds a layer without hiding anything. If you struggle to name the module without repeating the name of its main resource, use the resource directly.

  • Keep the tree flat. The documentation recommends a fairly flat module tree and module composition instead of deep nesting: usually the root module calls child modules, and children do not call further children.

For Bookshelf the three modules pass both tests. network turns a CIDR block into a set of named network tiers, database turns "I need PostgreSQL with durable storage and generated credentials" into one call, and service turns "run this image, N times, on these networks, with this health check" into one call. Each hides several resources and each has a vocabulary (tiers, replicas, health checks) that the individual resources do not.

The standard module structure

The standard module structure is a recommended file and directory layout for reusable modules that live in their own repositories. It is a convention rather than a schema, but tooling relies on it: the public registry and HCP Terraform parse it to generate documentation and to find submodules and examples, and terraform-docs expects it. Only the root module is strictly required, and the Terraform files must be in the root directory of the repository.

terraform-docker-bookshelf-database/
|-- README.md                  # what the module is for, a usage example, a diagram
|-- LICENSE                    # the license; without one many organisations will not adopt the module
|-- main.tf                    # primary entry point: the resources
|-- variables.tf               # input variable declarations
|-- outputs.tf                 # output value declarations
|-- versions.tf                # required_version and required_providers
|-- locals.tf                  # optional: derived values
|-- CHANGELOG.md               # optional but strongly recommended: what changed in each version
|-- .terraform-docs.yml        # optional: terraform-docs configuration
|-- templates/                 # optional: *.tftpl files read with templatefile()
|-- modules/                   # optional: nested modules
|   `-- backup-job/
|       |-- README.md          # a README marks a nested module as usable by external callers
|       |-- main.tf
|       |-- variables.tf
|       `-- outputs.tf
|-- examples/                  # runnable usage examples; each may have its own README
|   |-- minimal/
|   |   `-- main.tf
|   `-- complete/
|       `-- main.tf
`-- tests/                     # *.tftest.hcl files run by terraform test
    `-- database.tftest.hcl

The official page recommends this minimal layout: README.md, main.tf, variables.tf and outputs.tf. Everything else in the tree above is optional, and the versions.tf, locals.tf, templates/ and CHANGELOG.md entries are widespread community conventions rather than something the page requires.

Item Purpose and rules

README or README.md

The root module and every nested module should have one. The .md extension is treated as Markdown. It describes the module and its purpose, links to the examples/ directory for usage, and may include a diagram of the resources and how they relate. It does not need to document inputs and outputs, because tooling generates that (see Documenting with terraform-docs). When it links to files or images in the repository, use commit-specific absolute URLs, otherwise the links silently point at the wrong version later.

LICENSE

States the licence. Include it even when the module is not open source, because without a clear licence many organisations will not adopt a published module.

main.tf

The primary entry point. Complex modules may split resources over several files (network.tf, storage.tf) but nested module calls belong in the main file.

variables.tf, outputs.tf

The declarations of inputs and outputs. The documentation asks that all variables and outputs have a one- or two-sentence description, because the descriptions become the generated documentation.

versions.tf

Not mentioned by the structure page, but the common home of the terraform block with required_version and required_providers. Some authors keep these in main.tf; terraform-docs reads either.

modules/

Nested modules. A nested module with a README.md is considered usable by external callers; one without is internal to the module. The designation is advisory: Terraform does not stop anybody from calling an internal module. Nested modules are called with relative paths (./modules/backup-job), so Terraform treats them as part of the same package. They should be composable by the caller rather than calling each other, which avoids deep trees.

examples/

Usage examples at the repository root, each with its own README if useful. Examples for nested modules also live here. Because users copy examples elsewhere, their module blocks should use the source an external caller would use, not a relative path.

tests/

A common convention for .tftest.hcl files. The testing framework discovers test files by their extension (.tftest.hcl or .tftest.json); see Testing.

Repository naming

Two audiences care about the repository name. The public Terraform Registry requires the form terraform-<PROVIDER>-<NAME>, where <PROVIDER> is the main provider the module configures and <NAME> describes the infrastructure and may contain further hyphens (the documentation’s examples are terraform-google-vault and terraform-aws-ec2-instance). For Bookshelf this page uses terraform-docker-bookshelf-network, terraform-docker-bookshelf-database and terraform-docker-bookshelf-service, which would be published as module addresses of the form <namespace>/bookshelf-network/docker. Following the same convention in private repositories keeps a later move to a registry painless.

One repository per module or a monorepo?

The structure page assumes a repository per reusable module, and that is what the public registry expects. Many organisations nevertheless start with a monorepo containing modules/network, modules/database and modules/service next to the environments that call them, because a change to a module and to its callers lands in one pull request. The trade-off is versioning: callers in the same repository use relative paths and always see the latest code, so there is no stable interface to protect, and splitting later requires releasing versions. The usual path is a monorepo while the interface is still moving, then extraction of the modules that settle into their own repositories. The HCP Terraform private registry supports a monorepo with several modules by giving each a release tag prefix (see Publishing to the HCP Terraform private registry).

Designing the interface

The interface of a module is its variables and outputs. Everything else is implementation, and implementation can change in any release without breaking callers, provided the interface keeps its promises. Most module design decisions are therefore decisions about the interface.

Inputs

  • Be opinionated about the root module. The structure page says the root module "should be the primary entrypoint for the module and is expected to be opinionated". Hide detail callers should not have to decide (volume names, the health-check command of a well-known image) and expose the decisions that really vary (image tag, memory limit, replica count).

  • Type every variable and describe it for the caller. A type constraint turns a wrong value into an error at the module boundary; a description is the documentation. Write descriptions from the point of view of the user.

  • Give defaults to things that rarely change, and none to things that must be decided. A variable without a default is required, which is the right signal for a module’s name or its network. Defaults are part of the interface: changing one is a behavioural change.

  • Group related settings in an object rather than adding a dozen sibling variables (see Optional object attributes).

  • Mark secrets sensitive and prefer not to take them at all when the module can generate them (the Bookshelf database module generates its own password when the caller passes none). For values that must never reach the state see Sensitive Data.

  • Do not add a variable for every argument of every resource. Each variable is a promise to keep supporting it. Add arguments when a caller needs them, in a minor version.

Outputs

  • Export what callers need to connect other things — identifiers, names, addresses, ports — and nothing else. Every output is part of the interface.

  • Prefer returning whole objects or maps keyed by a stable name (network_names["backend"]) to dozens of flat outputs.

  • Mark an output sensitive when it carries a secret or is derived from one; Terraform insists on this when the value comes from a sensitive input.

  • Outputs may carry precondition blocks to enforce guarantees (see Assumptions and guarantees).

  • An output that is only useful for debugging belongs in the README, not in the interface.

Providers and the module

A reusable module declares which providers it needs but does not configure them.

  • Every module has its own required_providers inside a terraform block with the source address and a version constraint. For shared modules the documentation advises using only a minimum version (>=), so callers can choose newer versions that satisfy every module in the tree.

  • Provider configurations (a region, a host, credentials) belong to the caller. Child modules implicitly inherit the default (unaliased) configurations of their parent; the providers argument of the module block passes specific ones. Aliased configurations are never inherited and must be passed explicitly.

  • A module that needs more than one configuration of the same provider — a bucket replicated into a second region, for instance — declares them with configuration_aliases, and the caller maps each alias:

# versions.tf of a reusable module that replicates objects to a second region
terraform {
  required_version = ">= 1.9"

  required_providers {
    aws = {
      source                = "hashicorp/aws"
      version               = ">= 5.0"
      configuration_aliases = [aws.primary, aws.replica]
    }
  }
}

resource "aws_s3_bucket" "primary" {
  provider = aws.primary
  bucket   = var.bucket_name
}

resource "aws_s3_bucket" "replica" {
  provider = aws.replica
  bucket   = "${var.bucket_name}-replica"
}
  • Do not put provider blocks inside a reusable module. A provider configuration must exist for as long as the resources it manages, including during destruction. If a module contains both its resources and their provider block, removing the module call removes both at once, and the resources can no longer be destroyed. Such legacy modules also cannot be used with count, for_each or depends_on. See Providers for the full treatment.

Hiding versus exposing: a quick test

Before adding a variable ask: "will two callers of this module reasonably want different values?" If not, make it a locals constant. Before adding an output ask: "would a caller have to reach into my implementation to get this otherwise?" If not, leave it out. Both questions keep the interface small, and a small interface is the cheapest backward-compatibility policy there is.

Composition

Module composition is the documentation’s term for building a system from small, single-purpose modules, with the root module wiring them together by expressions. The guiding idea is that a module receives its dependencies from the caller instead of creating its own copies of them.

Dependency inversion

A module that needs a network accepts the network as input rather than building one. In the Bookshelf database module the network is an input (network_name), not a resource. The caller can then supply the name from a resource (module.network.network_names["backend"]), from a data source that looks up an existing network, or from a literal in a test. Nothing in the module changes between those cases, and the module is easier to refactor later because its dependencies are explicit.

# Created here: the network is a dependency the caller provides.
module "database" {
  source       = "./modules/database"
  name         = "postgres"
  network_name = module.network.network_names["backend"]
}

# Already exists: the very same module, fed from a data source.
data "docker_network" "shared" {
  name = "shared-backend"
}

module "database_on_shared_network" {
  source       = "./modules/database"
  name         = "postgres-shared"
  network_name = data.docker_network.shared.name
}

Conditional creation belongs to the caller

Rather than burying "create it or look it up" logic inside a module, let the caller decide. The documentation’s example is an AMI that comes from a resource in one environment and a data source in another, with the module taking the resulting identifier as one input either way. Reserve a boolean such as create for modules whose whole purpose is optional, and implement it with count or for_each (see Meta-Arguments) and the one() function to turn the zero-or-one list back into a single value.

Passing objects with only the attributes you need

A variable can use an object type that declares just the attributes the module reads. Any object having those attributes is accepted, so the caller can pass a whole resource, a data source or a hand-built object:

variable "network" {
  type = object({
    name = string
    id   = string
  })
  description = "Any object with a name and an id, such as a docker_network resource or data source."
}

Extra attributes on the object are discarded, which is what keeps the coupling loose. Compare this with passing network_name as a plain string: strings are simpler to read and to test; objects reduce the number of wiring lines in the root module. Pick one style per module family and keep it.

Assumptions and guarantees

The documentation frames a module’s contract as assumptions (conditions its inputs must meet) and guarantees (behaviour consumers can rely on). Variable validation blocks check assumptions at the boundary; precondition blocks in outputs check guarantees. Both are described in Lifecycle, Validation and Checks. A minimal guarantee (it assumes the caller keeps a tier named backend, so a real module would guard the lookup):

output "backend_network_name" {
  description = "Name of the internal backend network."
  value       = docker_network.tier["backend"].name

  precondition {
    condition     = docker_network.tier["backend"].internal
    error_message = "The backend tier must be an internal network; set internal = true for it in var.tiers."
  }
}

Multi-cloud abstractions and data-only modules

Terraform does not unify vendor services, but composition gets close: define a common object type (a DNS recordset, a database endpoint) and write one provider-specific module per vendor that takes and returns that shape. Switching vendors then means replacing one module block. A data-only module contains only data sources and wraps how existing infrastructure is found (a shared network, a central secret store). Give its outputs the same names as the management module’s outputs and callers can switch between creating and looking up without changing anything else.

Keep the tree shallow

Nested modules calling nested modules multiply the places a value can be wrong and make plan output hard to read. The standard structure page tells nested modules to be composable by the caller rather than to call each other. If a module needs the output of a sibling, pass it through the root module, as in Composing the Bookshelf environment.

Validating inputs

A module cannot trust its caller, so it validates. A validation block inside a variable has a condition (an expression that must be true) and an error_message. A variable may have several, and each failing one reports its message. Since Terraform 1.9 (and OpenTofu 1.9) a condition may refer to other objects of the module, including other variables, which makes cross-variable rules possible. The complete mechanics, including how a validation failure differs from a precondition, are in Variables, Outputs and Locals and Lifecycle, Validation and Checks; this section shows the patterns that recur in module development.

Pattern Example condition

Allowed set

contains(["unless-stopped", "on-failure", "always", "no"], var.restart)

Name shape

can(regex("^[a-z][a-z0-9-]{1,30}$", var.name))

Well-formed CIDR

can(cidrnetmask(var.cidr_block)) (IPv4 only; the function errors on anything that is not a valid IPv4 prefix)

Pinned image

can(regex(":[^/]$", var.image)) && !endswith(var.image, ":latest")+

Range

var.replicas >= 1 && var.replicas <= 10

Whole number

floor(var.replicas) == var.replicas

Optional value that must be valid when present

var.password == null || length(var.password) >= 16

Every element of a collection

alltrue([for p in var.ports : p.internal >= 1 && p.internal <= 65535])

Cross-variable (1.9 and later)

var.replicas == 1 || alltrue([for p in var.ports : p.external == null])

Three habits make validations pleasant for callers:

  • Write the error message as the fix, not as the symptom: "The image must carry an explicit tag other than latest, for example postgres:16" beats "invalid image".

  • Use can() around functions that raise errors on bad input (regex, cidrnetmask, cidrhost), so a malformed value yields your message instead of a function error.

  • Remember that null short-circuits nothing by itself: a condition on an optional variable must handle null explicitly, as in var.password == null || ....

Optional object attributes

Modules with many settings quickly want a variable that is an object whose attributes mostly have sensible defaults. The optional() modifier makes that possible: inside an object({...}) type constraint, optional(type) marks an attribute as not required, and optional(type, default) supplies a default. The documented behaviour:

  • If no default is given, an omitted attribute is null of the appropriate type.

  • An attribute with a non-null default never becomes null in the module: Terraform substitutes the default both when the caller omits the attribute and when the caller sets it explicitly to null.

  • Defaults are applied from the outside in. The default of an outer optional is applied first, then the defaults of attributes nested inside it. That is why optional(object({...}), {}) works as "the whole group is optional, and its members take their own defaults".

The official example is the shape to copy:

variable "with_optional_attribute" {
  type = object({
    a = string                # a required attribute
    b = optional(string)      # an optional attribute
    c = optional(number, 127) # an optional attribute with default value
  })
}

The Bookshelf database module applies this to a resources setting. Callers can pass {}, or only the attribute they care about:

variable "resources" {
  type = object({
    memory_mb = optional(number, 512)
    restart   = optional(string, "unless-stopped")
    volume = optional(object({
      driver = optional(string, "local")
      labels = optional(map(string), {})
    }), {})
  })
  description = "Runtime settings of the database container. Every attribute is optional."
  default     = {}

  validation {
    condition     = var.resources.memory_mb >= 128
    error_message = "resources.memory_mb must be at least 128."
  }

  validation {
    condition     = contains(["no", "on-failure", "always", "unless-stopped"], var.resources.restart)
    error_message = "resources.restart must be one of no, on-failure, always or unless-stopped."
  }
}

A caller writing resources = { memory_mb = 1024 } ends up with restart = "unless-stopped" and a volume whose driver is the string local, because the outer default {} for volume is applied first and its inner defaults after. Practical points:

  • Give the variable a default = {} as well when every attribute is optional, otherwise the variable itself is required. The type’s defaults then fill the empty object.

  • The type constraint documents the shape; the validations check the values. Together they replace a page of README.

  • An optional attribute without a default is null when absent, so code that reads it must handle null (var.healthcheck == null ? [] : [var.healthcheck] in a dynamic block).

  • Adding a new optional attribute with a default is a backward-compatible change (a minor version). Making an optional attribute required, or removing one, is breaking. See Versioning.

Optional attributes with defaults have been stable since Terraform 1.3 (2022-09-21); see Types and Expressions for the complete treatment, including conversion rules and any.

Templates

"Template" means three different things in module work, and all three are useful.

Template repositories

A template repository on GitHub lets a team start every new module from the same skeleton: the standard layout, a .gitignore, a .pre-commit-config.yaml, a CI workflow, an empty CHANGELOG.md and a .terraform-docs.yml. Creating a repository from a template copies the directory structure and files of the default branch (optionally all branches) but not the commit history: the new repository starts with a single commit and has an unrelated history, so you cannot merge between it and the template. That is the property you want for a scaffold, and the difference from a fork. In the web UI choose Use this template then Create a new repository; the GitHub CLI offers gh repo create with a template option for the same job.

A template repository is the right place to encode house rules (provider version policy, required files, the CI pipeline of CI/CD and Automation) so that individual modules do not re-invent them.

A GitHub template copies files verbatim, so every new module still needs its names edited by hand. A project generator such as Cookiecutter fills that gap: the template repository holds placeholder file contents and a cookiecutter.json file that defines the values to prompt for (module name, provider, owner), and cookiecutter gh:<org>/<template-repository> (or a full Git URL) renders a ready-named skeleton. Any Git host works, and private repositories are addressed with a git+ URL prefix.

templatefile() inside a module

Modules often render configuration files for the workload they deploy. Keep the template in a templates/ directory and render it with templatefile(), addressing the file relative to the module with path.module, which is the filesystem path of the module containing the expression. This matters because a module is called from other directories, and a bare relative path would resolve against the caller’s working directory.

# modules/service/templates/nginx.conf.tftpl
server {
  listen 80;
  server_name ${server_name};

%{ for upstream in upstreams ~}
  location /${upstream.path}/ {
    proxy_pass http://${upstream.host}:${upstream.port}/;
  }
%{ endfor ~}
}
# modules/service/main.tf (excerpt)
locals {
  nginx_conf = templatefile("${path.module}/templates/nginx.conf.tftpl", {
    server_name = var.name
    upstreams   = var.upstreams
  })
}

output "rendered_nginx_conf" {
  description = "The rendered reverse-proxy configuration, for inspection and tests."
  value       = local.nginx_conf
}

The .tftpl extension is a convention that editors and linters recognise. The template syntax (${...} interpolation and %{ ... } directives) is covered in Types and Expressions and Functions. Exposing the rendered text as an output makes the template testable without applying anything.

Example scaffolds

examples/ directories are templates for callers: they show a working module block that can be copied. Keep them runnable (CI should init and plan them) and keep their source pointing at the published address. The first example should be the smallest call that works (minimal); a second should exercise most options (complete).

Documenting with terraform-docs

terraform-docs is a utility that generates documentation from Terraform modules in several output formats. For a module README the usual format is a Markdown table of requirements, providers, resources, inputs and outputs, built from the required_providers, resource, variable and output blocks and their descriptions. That is why the standard structure page insists on descriptions and says the README does not have to document inputs and outputs by hand.

Install it with Homebrew (brew install terraform-docs), go install github.com/terraform-docs/terraform-docs@v0.24.0, the release binaries, or the container image quay.io/terraform-docs/terraform-docs (the version shown is the one on the installation page when this was written; use the current release).

Injecting into the README

The inject output mode (the default) replaces only the part of a file between a pair of markers, creating the file when it does not exist and appending when the markers are absent. The default markers are HTML comments:

# Bookshelf database module

Runs PostgreSQL with a named volume, a health check and a generated password.

<!-- BEGIN_TF_DOCS -->
<!-- END_TF_DOCS -->
terraform-docs markdown table --output-file README.md --output-mode inject .

The other mode, replace, overwrites the whole file, so manual edits to it are lost. The comment wording between <!-- and --> can change but the comments must remain; other styles such as [//]: # (BEGIN_TF_DOCS) are supported for platforms without HTML comments. The --output-file path is relative to the module path or absolute.

A configuration file

Put the settings in .terraform-docs.yml at the module root so everyone (and CI) runs the same command. The file is loaded with -c or --config, with a relative or absolute path:

# .terraform-docs.yml
formatter: "markdown table"

output:
  file: README.md
  mode: inject
  template: |-
    <!-- BEGIN_TF_DOCS -->
    {{ .Content }}
    <!-- END_TF_DOCS -->
terraform-docs -c .terraform-docs.yml .
# for a repository with several modules, run it once per module directory
for dir in modules/*/; do terraform-docs -c .terraform-docs.yml "$dir"; done

Keeping the README in sync

Generated documentation rots unless a machine checks it. Two approaches work together:

  • A pre-commit hook that regenerates the README before every commit:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/terraform-docs/terraform-docs
    rev: "v0.24.0"   # use the current release tag
    hooks:
      - id: terraform-docs-go
        args: ["--output-file", "README.md", "./"]

The hook id terraform-docs-docker can be used instead when Go is not installed.

HCP Terraform and the public registry also render documentation from the source themselves, including submodules and examples, so the README is not the only place users see the interface. A README generated by terraform-docs is still the version people read on GitHub and the version that survives a move to another registry.

Building the Bookshelf modules

The following three modules are complete and consistent: each has versions.tf, variables.tf, main.tf and outputs.tf, validates its inputs, and is called from the envs/dev root module. In the monorepo form used while developing, the layout is the one the rest of this section reuses (see Workspaces, Environments and Project Structure):

bookshelf-infra/
|-- modules/
|   |-- network/
|   |   |-- versions.tf
|   |   |-- variables.tf
|   |   |-- main.tf
|   |   |-- outputs.tf
|   |   |-- README.md
|   |   |-- examples/complete/main.tf
|   |   `-- tests/network.tftest.hcl
|   |-- database/
|   |   `-- (same files)
|   `-- service/
|       `-- (same files)
`-- envs/
    |-- dev/
    |   |-- main.tf
    |   |-- variables.tf
    |   `-- terraform.tfvars
    `-- prod/
        `-- (same files)

When a module is extracted to its own repository (terraform-docker-bookshelf-network) the contents of modules/network/ become the repository root, and examples/complete/main.tf changes its source from a relative path to the published address.

The network module

The network module turns one CIDR block into several named tiers. Each tier becomes a Docker network whose subnet is a slice of the CIDR block computed with cidrsubnet(), and a tier can be internal, meaning containers on it have no access to the outside world. Bookshelf uses two tiers: frontend (where web lives) and backend (where the database lives and which is internal).

# modules/network/versions.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = ">= 4.0"
    }
  }
}
# modules/network/variables.tf
variable "name" {
  type        = string
  description = "Prefix for the network names. The tier name is appended, for example bookshelf-dev-backend."

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,30}$", var.name))
    error_message = "The name must start with a letter and contain 2 to 31 lowercase letters, digits or hyphens."
  }
}

variable "cidr_block" {
  type        = string
  description = "IPv4 CIDR block that is divided into one subnet per tier."
  default     = "172.28.0.0/16"

  validation {
    condition     = can(cidrnetmask(var.cidr_block))
    error_message = "The cidr_block must be a valid IPv4 CIDR prefix such as 172.28.0.0/16."
  }
}

variable "subnet_newbits" {
  type        = number
  description = "Number of additional prefix bits for each tier subnet; a /16 block with 8 yields /24 subnets."
  default     = 8

  validation {
    condition     = var.subnet_newbits >= 1 && floor(var.subnet_newbits) == var.subnet_newbits
    error_message = "The subnet_newbits value must be a whole number of at least 1."
  }
}

variable "tiers" {
  type = map(object({
    netnum   = number
    internal = optional(bool, false)
  }))
  description = "Network tiers. The key is the tier name; netnum selects which subnet of the block it receives."
  default = {
    frontend = { netnum = 1 }
    backend  = { netnum = 2, internal = true }
  }

  validation {
    condition     = length(var.tiers) > 0
    error_message = "At least one tier is required."
  }

  validation {
    condition     = length(distinct([for t in values(var.tiers) : t.netnum])) == length(var.tiers)
    error_message = "Each tier must use a different netnum, otherwise their subnets would overlap."
  }

  # Cross-variable rule (Terraform and OpenTofu 1.9 and later): every netnum must fit in the number of new bits.
  validation {
    condition     = alltrue([for t in values(var.tiers) : t.netnum >= 0 && t.netnum < pow(2, var.subnet_newbits)])
    error_message = "Each netnum must be between 0 and 2^subnet_newbits - 1."
  }
}

variable "labels" {
  type        = map(string)
  description = "Labels added to every network."
  default     = {}
}
# modules/network/main.tf
locals {
  subnets = {
    for tier, cfg in var.tiers :
    tier => cidrsubnet(var.cidr_block, var.subnet_newbits, cfg.netnum)
  }

  labels = merge(var.labels, { "bookshelf.module" = "network" })
}

resource "docker_network" "tier" {
  for_each = var.tiers

  name     = "${var.name}-${each.key}"
  driver   = "bridge"
  internal = each.value.internal

  ipam_config {
    subnet  = local.subnets[each.key]
    gateway = cidrhost(local.subnets[each.key], 1)
  }

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }
}
# modules/network/outputs.tf
output "network_names" {
  description = "Docker network name of each tier, keyed by tier name."
  value       = { for tier, net in docker_network.tier : tier => net.name }
}

output "network_ids" {
  description = "Docker network id of each tier, keyed by tier name."
  value       = { for tier, net in docker_network.tier : tier => net.id }
}

output "cidr_blocks" {
  description = "Subnet CIDR of each tier, keyed by tier name."
  value       = local.subnets
}

output "gateways" {
  description = "Gateway address of each tier, keyed by tier name."
  value       = { for tier, cidr in local.subnets : tier => cidrhost(cidr, 1) }
}

Design notes on this module:

  • The tiers are a map and the resource uses for_each, so adding or removing a tier changes only that tier’s address (docker_network.tier["backend"]), never its neighbours' (see Meta-Arguments).

  • Using one subnet_newbits for every tier plus a uniqueness check on netnum guarantees that subnets never overlap without writing any CIDR-overlap arithmetic.

  • The default for tiers makes the module usable with only name. The optional(bool, false) on internal means most tiers need only a netnum.

  • The same pattern (a CIDR block cut into named slices) is how cloud network modules derive public, private and database subnets; see Terraform on AWS for a cloud version.

  • An examples/complete directory shows the smallest working call:

# modules/network/examples/complete/main.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = ">= 4.0"
    }
  }
}

provider "docker" {}

module "network" {
  # When published, use the address an external caller would use, for example:
  # source  = "example-org/bookshelf-network/docker"
  # version = "~> 1.0"
  source = "../.."

  name       = "bookshelf-example"
  cidr_block = "10.40.0.0/16"

  tiers = {
    frontend = { netnum = 1 }
    backend  = { netnum = 2, internal = true }
    admin    = { netnum = 10 }
  }
}

output "cidr_blocks" {
  value = module.network.cidr_blocks
}

With the default /16 and eight new bits, the tier backend (netnum = 2) receives the second-numbered /24 of the block, for instance 172.28.2.0/24, and its gateway is the first host address of that subnet (172.28.2.1). An illustrative terraform plan summary for the example would end with Plan: 3 to add, 0 to change, 0 to destroy. and outputs such as the following (abbreviated; the shape, not verbatim output):

$ terraform -chdir=examples/complete plan
...
Changes to Outputs:
  + cidr_blocks = {
      + admin    = "10.40.10.0/24"
      + backend  = "10.40.2.0/24"
      + frontend = "10.40.1.0/24"
    }

The database module

The database module runs PostgreSQL in a container with a named volume, a health check and credentials. If the caller supplies no password it generates one with the random provider; the connection string is exported as a sensitive output. It receives the network as an input (dependency inversion) and never creates one.

# modules/database/versions.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = ">= 4.0"
    }
    random = {
      source  = "hashicorp/random"
      version = ">= 3.5"
    }
  }
}
# modules/database/variables.tf
variable "name" {
  type        = string
  description = "Name of the database container. It is also the DNS alias other containers use to reach it."

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,30}$", var.name))
    error_message = "The name must start with a letter and contain 2 to 31 lowercase letters, digits or hyphens."
  }
}

variable "network_name" {
  type        = string
  description = "Name of the Docker network the database joins. Create it with the network module."

  validation {
    condition     = length(var.network_name) > 0
    error_message = "The network_name must not be empty."
  }
}

variable "image_tag" {
  type        = string
  description = "Tag of the postgres image."
  default     = "16"

  validation {
    condition     = can(regex("^[0-9]+(\\.[0-9]+)?(-[a-z0-9]+)?$", var.image_tag))
    error_message = "The image_tag must look like 16, 16.4 or 16-alpine; floating tags such as latest are not allowed."
  }
}

variable "database_name" {
  type        = string
  description = "Name of the database created on first start."
  default     = "bookshelf"
}

variable "username" {
  type        = string
  description = "Name of the superuser created on first start."
  default     = "bookshelf"
}

variable "password" {
  type        = string
  description = "Password of the superuser. When null the module generates one."
  default     = null
  sensitive   = true
  nullable    = true

  validation {
    condition     = var.password == null || length(var.password) >= 16
    error_message = "A supplied password must be at least 16 characters long."
  }
}

variable "resources" {
  type = object({
    memory_mb = optional(number, 512)
    restart   = optional(string, "unless-stopped")
    volume = optional(object({
      driver = optional(string, "local")
      labels = optional(map(string), {})
    }), {})
  })
  description = "Runtime settings of the database container. Every attribute is optional."
  default     = {}

  validation {
    condition     = var.resources.memory_mb >= 128
    error_message = "resources.memory_mb must be at least 128."
  }

  validation {
    condition     = contains(["no", "on-failure", "always", "unless-stopped"], var.resources.restart)
    error_message = "resources.restart must be one of no, on-failure, always or unless-stopped."
  }
}
# modules/database/main.tf
locals {
  password = coalesce(var.password, one(random_password.this[*].result))
  port     = 5432
}

resource "random_password" "this" {
  count = var.password == null ? 1 : 0

  length  = 24
  special = false # keeps the value safe to embed in a connection URL
}

resource "docker_image" "this" {
  name         = "postgres:${var.image_tag}"
  keep_locally = true
}

resource "docker_volume" "data" {
  name   = "${var.name}-data"
  driver = var.resources.volume.driver

  dynamic "labels" {
    for_each = var.resources.volume.labels
    content {
      label = labels.key
      value = labels.value
    }
  }
}

resource "docker_container" "this" {
  name    = var.name
  image   = docker_image.this.image_id
  restart = var.resources.restart
  memory  = var.resources.memory_mb

  env = [
    "POSTGRES_DB=${var.database_name}",
    "POSTGRES_USER=${var.username}",
    "POSTGRES_PASSWORD=${local.password}",
  ]

  networks_advanced {
    name    = var.network_name
    aliases = [var.name]
  }

  volumes {
    volume_name    = docker_volume.data.name
    container_path = "/var/lib/postgresql/data"
  }

  healthcheck {
    test         = ["CMD-SHELL", "pg_isready -U ${var.username} -d ${var.database_name}"]
    interval     = "10s"
    timeout      = "5s"
    retries      = 5
    start_period = "20s"
  }
}
# modules/database/outputs.tf
output "host" {
  description = "DNS name other containers on the same network use to reach the database."
  value       = var.name
}

output "port" {
  description = "TCP port PostgreSQL listens on inside the network."
  value       = local.port
}

output "database_name" {
  description = "Name of the database created on first start."
  value       = var.database_name
}

output "username" {
  description = "Name of the superuser."
  value       = var.username
}

output "password" {
  description = "Password of the superuser (supplied or generated)."
  value       = local.password
  sensitive   = true
}

output "connection_url" {
  description = "PostgreSQL connection URL including the credentials."
  value = format(
    "postgres://%s:%s@%s:%d/%s",
    urlencode(var.username),
    urlencode(local.password),
    var.name,
    local.port,
    var.database_name,
  )
  sensitive = true
}

output "container_id" {
  description = "Id of the database container, for depends_on-style ordering in callers."
  value       = docker_container.this.id
}

Design notes:

  • coalesce(var.password, one(random_password.this[*].result)) picks the caller’s value, or the generated one when the generator exists. one() turns the zero-or-one-element list produced by count back into a single value or null. The random_password resource marks result as sensitive, and the sensitivity propagates to the outputs.

  • The password is still written to the state (the random_password page says so explicitly, and the container’s env argument records it as well). For production, protect the state backend (see Backends and Remote State) and use the write-only and ephemeral features described in Sensitive Data where the provider supports them.

  • The connection_url is sensitive because it embeds the password; Terraform would refuse to show it in plan output without the marking, and terraform output -raw connection_url still prints it on demand.

  • The module exports host as the name alias, not an IP address, so callers never depend on addressing that Docker may change.

  • The volume is a separate resource named after the container. Destroying the module destroys the volume and its data. For data that must survive, add prevent_destroy through the caller’s workflow or keep the volume outside the module and pass it in (dependency inversion again).

  • The healthcheck lets docker ps and orchestration report readiness; Terraform itself does not wait for health.

The service module

The service module runs N replicas of a container image on one or more networks. It covers catalog-api, reviews-api and web alike. Its interface is the most varied of the three, which makes it the best illustration of optional(), dynamic blocks and cross-variable validation.

# modules/service/versions.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = ">= 4.0"
    }
  }
}
# modules/service/variables.tf
variable "name" {
  type        = string
  description = "Service name. It is the DNS alias of every replica and the prefix of the container names."

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,30}$", var.name))
    error_message = "The name must start with a letter and contain 2 to 31 lowercase letters, digits or hyphens."
  }
}

variable "image" {
  type        = string
  description = "Container image with an explicit tag, for example nginx:1.27."

  validation {
    condition     = can(regex(":[^/]+$", var.image)) && !endswith(var.image, ":latest")
    error_message = "The image must carry an explicit tag other than latest, for example nginx:1.27."
  }
}

variable "networks" {
  type        = list(string)
  description = "Names of the Docker networks every replica joins, for example from the network module."

  validation {
    condition     = length(var.networks) > 0
    error_message = "The service must join at least one network."
  }
}

variable "replicas" {
  type        = number
  description = "Number of identical containers."
  default     = 1

  validation {
    condition     = var.replicas >= 1 && var.replicas <= 10 && floor(var.replicas) == var.replicas
    error_message = "The replicas value must be a whole number between 1 and 10."
  }
}

variable "env" {
  type        = map(string)
  description = "Environment variables that are not secret."
  default     = {}
}

variable "secret_env" {
  type        = map(string)
  description = "Environment variables that are secret, such as connection strings. Never shown in plan output."
  default     = {}
  sensitive   = true
}

variable "ports" {
  type = list(object({
    internal = number
    external = optional(number)
    protocol = optional(string, "tcp")
  }))
  description = "Ports to publish. Leave external unset to expose a port only inside the networks."
  default     = []

  validation {
    condition     = alltrue([for p in var.ports : p.internal >= 1 && p.internal <= 65535])
    error_message = "Every internal port must be between 1 and 65535."
  }

  # Cross-variable rule: replicas would collide on the same published host port.
  validation {
    condition     = var.replicas == 1 || alltrue([for p in var.ports : p.external == null])
    error_message = "A published external port needs replicas = 1; leave external unset when running more replicas."
  }
}

variable "healthcheck" {
  type = object({
    test         = list(string)
    interval     = optional(string, "30s")
    timeout      = optional(string, "5s")
    retries      = optional(number, 3)
    start_period = optional(string, "10s")
  })
  description = "Container health check. Null disables it."
  default     = null
}

variable "resources" {
  type = object({
    memory_mb = optional(number, 256)
    restart   = optional(string, "unless-stopped")
  })
  description = "Runtime settings of each replica. Every attribute is optional."
  default     = {}

  validation {
    condition     = var.resources.memory_mb >= 64
    error_message = "resources.memory_mb must be at least 64."
  }

  validation {
    condition     = contains(["no", "on-failure", "always", "unless-stopped"], var.resources.restart)
    error_message = "resources.restart must be one of no, on-failure, always or unless-stopped."
  }
}

variable "labels" {
  type        = map(string)
  description = "Labels added to every container."
  default     = {}
}
# modules/service/main.tf
locals {
  replica_keys = toset([for i in range(var.replicas) : tostring(i)])

  env = concat(
    [for k, v in var.env : "${k}=${v}"],
    [for k, v in var.secret_env : "${k}=${v}"],
  )

  labels = merge(var.labels, { "bookshelf.service" = var.name })
}

resource "docker_image" "this" {
  name         = var.image
  keep_locally = true
}

resource "docker_container" "this" {
  for_each = local.replica_keys

  name    = "${var.name}-${each.key}"
  image   = docker_image.this.image_id
  restart = var.resources.restart
  memory  = var.resources.memory_mb
  env     = local.env

  dynamic "networks_advanced" {
    for_each = toset(var.networks)
    content {
      name    = networks_advanced.value
      aliases = [var.name]
    }
  }

  dynamic "ports" {
    for_each = var.ports
    content {
      internal = ports.value.internal
      external = ports.value.external
      protocol = ports.value.protocol
    }
  }

  dynamic "healthcheck" {
    for_each = var.healthcheck == null ? [] : [var.healthcheck]
    content {
      test         = healthcheck.value.test
      interval     = healthcheck.value.interval
      timeout      = healthcheck.value.timeout
      retries      = healthcheck.value.retries
      start_period = healthcheck.value.start_period
    }
  }

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }
}
# modules/service/outputs.tf
output "name" {
  description = "Service name; also the DNS alias on every network the service joins."
  value       = var.name
}

output "container_names" {
  description = "Names of the replica containers."
  value       = [for key in sort(keys(docker_container.this)) : docker_container.this[key].name]
}

output "container_ids" {
  description = "Ids of the replica containers, keyed by replica index."
  value       = { for key, c in docker_container.this : key => c.id }
}

output "internal_urls" {
  description = "In-network URLs of the declared ports, keyed by internal port. Empty when no port is declared."
  value       = { for p in var.ports : tostring(p.internal) => "${p.protocol == "tcp" ? "http" : p.protocol}://${var.name}:${p.internal}" }
}

Design notes:

  • All replicas share the alias var.name on each network, so Docker’s embedded DNS resolves the service name to the replicas. Whether that balances load is a property of Docker’s DNS, not of this module; do not promise more in the README than the runtime delivers.

  • dynamic blocks are used where a nested block is genuinely optional or repeated (ports, healthcheck, labels, networks_advanced). They are not a general-purpose abstraction; see Types and Expressions for when not to use them.

  • The secret_env variable is sensitive, and the combined local.env inherits the marking, so the secrets never appear in plan output. They are still recorded in the state as part of the container’s arguments.

  • Pinning the image tag in validation prevents latest, which makes plan unreproducible. The module does not do deployment ordering: readiness of the database is the caller’s concern, which is why the database exports its container id.

  • Replica containers are keyed by index strings with for_each, never by count, so reducing replicas removes only the highest-numbered replicas without renumbering the rest.

Composing the Bookshelf environment

The envs/dev root module is the only place that knows how the pieces fit together. It calls the three modules, passes outputs of one as inputs of the next, and owns the provider configuration and the variables that differ per environment. Terraform builds the dependency graph from those references, so no depends_on is needed.

# envs/dev/main.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.5"
    }
  }
}

provider "docker" {}

module "network" {
  source = "../../modules/network"

  name       = "bookshelf-dev"
  cidr_block = "172.28.0.0/16"
}

module "database" {
  source = "../../modules/database"

  name         = "postgres"
  network_name = module.network.network_names["backend"]
  image_tag    = "16"
  resources    = { memory_mb = 512 }
}

module "catalog_api" {
  source = "../../modules/service"

  name     = "catalog-api"
  image    = var.catalog_api_image
  replicas = var.catalog_api_replicas
  networks = [
    module.network.network_names["frontend"],
    module.network.network_names["backend"],
  ]

  env = {
    DATABASE_HOST = module.database.host
    DATABASE_PORT = tostring(module.database.port)
    DATABASE_NAME = module.database.database_name
  }
  secret_env = {
    DATABASE_URL = module.database.connection_url
  }

  ports       = [{ internal = 8080 }]
  healthcheck = { test = ["CMD", "wget", "-qO-", "http://localhost:8080/health"] }
}

module "reviews_api" {
  source = "../../modules/service"

  name     = "reviews-api"
  image    = var.reviews_api_image
  networks = [
    module.network.network_names["frontend"],
    module.network.network_names["backend"],
  ]

  secret_env = {
    DATABASE_URL = module.database.connection_url
  }

  ports = [{ internal = 8081 }]
}

module "web" {
  source = "../../modules/service"

  name     = "web"
  image    = "nginx:1.27"
  networks = [module.network.network_names["frontend"]]

  env = {
    CATALOG_API_URL = module.catalog_api.internal_urls["8080"]
    REVIEWS_API_URL = module.reviews_api.internal_urls["8081"]
  }

  ports = [{ internal = 80, external = 8080 }]
}

variable "catalog_api_image" {
  type        = string
  description = "Image of catalog-api, with an explicit tag. Placeholder registry shown."
  default     = "ghcr.io/example-org/bookshelf-catalog-api:1.4.2"
}

variable "catalog_api_replicas" {
  type    = number
  default = 2
}

variable "reviews_api_image" {
  type        = string
  description = "Image of reviews-api, with an explicit tag. Placeholder registry shown."
  default     = "ghcr.io/example-org/bookshelf-reviews-api:2.0.1"
}

output "web_url" {
  value = "http://localhost:8080"
}

output "network_cidrs" {
  value = module.network.cidr_blocks
}

Points worth noticing in the composition:

  • module.database.connection_url flows into secret_env, a sensitive variable, so the sensitivity is consistent from the generator to the container.

  • web is on the frontend tier only. The backend tier is internal = true, so the database is unreachable from outside Docker, and web cannot reach it at all. This is the whole point of exposing the tiers as separate networks.

  • The images for catalog-api and reviews-api use a placeholder registry (the ghcr.io/example-org namespace); replace them with your own images. No real credentials appear anywhere; secrets are generated, passed through outputs, or provided with TF_VAR_ variables.

  • The module calls could use for_each to stamp out several services from one map; see Modules for count, for_each and depends_on on module blocks.

  • Per-environment differences (replica counts, memory, CIDR blocks) live in terraform.tfvars of each environment, not in the modules.

$ cd envs/dev
$ terraform init
Initializing the backend...
Initializing modules...
- catalog_api in ../../modules/service
- database in ../../modules/database
- network in ../../modules/network
- reviews_api in ../../modules/service
- web in ../../modules/service
...
Terraform has been successfully initialized!

$ terraform apply
...
Plan: 13 to add, 0 to change, 0 to destroy.
...
Outputs:

network_cidrs = {
  "backend" = "172.28.2.0/24"
  "frontend" = "172.28.1.0/24"
}
web_url = "http://localhost:8080"

The output above is abbreviated and illustrative: the exact resource count and message wording depend on the Terraform and provider versions. terraform init installs local modules by referencing them in place; remote modules are downloaded into .terraform/modules. For the diagram of this composition see Module composition diagram.

Testing a module

Modules are the main customer of the native testing framework (available from Terraform 1.6). A test file ending in .tftest.hcl contains run blocks, each of which plans or applies the configuration in the directory where terraform test is run and checks assert conditions. A run block can also contain a module block that points at an alternate module (only source and version are supported there, and only local and registry sources) to test a module against another one. A first plan-only test for the network module asserts on planned values, so it does not need the containers to run:

# modules/network/tests/network.tftest.hcl
variables {
  name       = "bookshelf-test"
  cidr_block = "10.0.0.0/16"
}

run "default_tiers_are_planned" {
  command = plan

  assert {
    condition     = output.cidr_blocks["frontend"] == "10.0.1.0/24"
    error_message = "The frontend tier should receive the first numbered /24 of the block."
  }

  assert {
    condition     = output.cidr_blocks["backend"] == "10.0.2.0/24"
    error_message = "The backend tier should receive the second numbered /24 of the block."
  }
}

run "overlapping_tiers_are_rejected" {
  command = plan

  variables {
    tiers = {
      a = { netnum = 1 }
      b = { netnum = 1 }
    }
  }

  expect_failures = [var.tiers]
}

Testing examples/ directories in CI (an init, validate and plan, and an apply against a disposable environment for the main example) is a good smoke test of the published interface. Mocking providers, integration tests and the Go-based Terratest are covered in Testing.

Versioning

A published module is a dependency of other people’s infrastructure, so its version numbers carry meaning. Modules use semantic versioning (SemVer): MAJOR.MINOR.PATCH, where a MAJOR increment signals an incompatible change to the public API, MINOR a backward-compatible addition and PATCH a backward-compatible fix. The SemVer specification adds that version 0.y.z is for initial development and anything may change, that 1.0.0 defines the public API, that pre-release identifiers are added after a hyphen (1.0.0-rc.1) and rank below the release, and that "the contents of that version MUST NOT be modified" once released. Terraform’s own registries apply the same logic through version constraints.

The public API of a module is its interface plus the identity of the objects it manages:

Change Bump Why

Documentation, comments, formatting

PATCH

No behavioural change.

Bug fix that makes the module do what its README says

PATCH

The documented behaviour is the contract.

Add an optional variable with a default; add an output; add an optional attribute with a default to an object

MINOR

Existing callers need no change.

Add a new resource that callers did not ask for, with no impact on existing resources

MINOR (judge carefully)

It creates real infrastructure and cost on the next apply; many authors gate it behind a flag that defaults to off.

Change a default value in a way that changes what is created

MAJOR

Existing callers get a different plan.

Remove or rename a variable or output; make an optional variable required; tighten a variable’s type

MAJOR

Existing callers no longer validate or plan.

Rename, move or change the count / for_each keys of a resource without a moved block

MAJOR

The plan would destroy and recreate it. With a moved block the change can be a MINOR (see below).

Raise required_version, or raise the minimum of a provider across a major version

MAJOR (provider major) or MINOR (core)

Callers must upgrade their toolchain; for providers, a major bump often brings its own breaking changes.

Remove a deprecated variable or output

MAJOR

The removal is the breaking change; the deprecation was the warning.

Evolving without breaking

  • Keep moved blocks. Using moved blocks to record module address refactoring requires Terraform 1.1 or later. They map an old address to a new one so Terraform plans a move instead of a destroy and create. Removing a moved block is itself a breaking change, because callers who skipped intermediate versions would plan a deletion. The documentation recommends keeping historical moved blocks and chaining them when an object is renamed more than once (a to b, then b to c), so every earlier version still upgrades. Removing them is safe only in controlled cases such as private modules where every user is known to have applied the newer version. A moved block can also target an instance key when the destination module is called with count or for_each:

# modules/service/moved.tf -- a rename shipped in a minor release
moved {
  from = docker_container.replica
  to   = docker_container.this
}
  • Deprecate before removing. The deprecated argument on a variable or output (Terraform 1.15, OpenTofu 1.10) makes Terraform warn callers that still use it, with your message explaining the replacement. A caller that cannot act yet can silence nested warnings with ignore_nested_deprecations on the module block (Terraform 1.15). Ship the deprecation in a minor release, the removal in the next major release.

  • Document breaking changes in a changelog following the Keep a Changelog or Conventional Commits conventions, and say how to migrate. A major version is much less painful with a migration paragraph and the necessary moved blocks.

  • Do not rewrite published tags. Registries cache versions, and consumers rely on a version meaning the same code forever. If a release is broken, publish a fixed PATCH and, if necessary, retire the broken one.

See Refactoring: moved and removed for the full treatment of moved and removed blocks, terraform state mv and large-scale restructuring.

Tagging a release

The public registry and HCP Terraform detect versions from Git tags: a tag must be a semantic version in x.y.z form, optionally prefixed with v (for example v1.0.4 or 0.9.2), and tags that do not look like version numbers are ignored. At least one valid tag is needed before a module can be published.

git switch main && git pull --ff-only
git tag -a v1.2.0 -m "network: add the admin tier example and the gateways output"
git push origin v1.2.0

Pushing the tag is the release. There is no separate publish step for a module already registered; the registry’s webhook notifies it and the new version usually appears within a minute.

How callers consume versions

Callers pin with the version argument of the module block (registry sources only) or with ?ref= on a Git source. A typical convention is to allow compatible updates within a major version and let Dependabot or Renovate propose the rest:

module "network" {
  source  = "example-org/bookshelf-network/docker"
  version = "~> 1.2"   # any 1.x release from 1.2 up, never 2.0
}

module "database_from_git" {
  source = "git::https://github.com/example-org/terraform-docker-bookshelf-database.git?ref=v1.4.0"
  # Git sources have no version argument: the ref is the version.
}

The version argument applies only to modules from a registry; local paths do not support it. See Modules for the complete list of source types and how version constraints are resolved.

Publishing

How a module is published depends on who is allowed to use it. The options form a spectrum from public to private.

Channel How callers reference it What publishing involves

Public Terraform Registry

<namespace>/<name>/<provider> with version

A public GitHub repository named terraform-<PROVIDER>-<NAME>, the standard structure, at least one x.y.z tag, and registering the repository on the registry website.

HCP Terraform / Terraform Enterprise private registry

app.terraform.io/<ORG>/<NAME>/<PROVIDER> with version

A repository in a connected VCS provider (tag or branch based) or an upload through the API.

A registry that implements the module registry protocol (self-hosted or a product such as Artifactory)

<hostname>/<namespace>/<name>/<system> with version

Whatever the implementation requires; the protocol only defines how Terraform discovers and downloads versions.

Git (or HTTP, S3, GCS) directly

a Git URL with a ref query argument

Push a tag. No metadata, no registry UI, no version constraints.

OpenTofu Registry

<namespace>/<name>/<provider> with version (from tofu)

Submit the module through the registry’s issue form; review by the OpenTofu team.

Publishing to the public Terraform Registry

The documented requirements for the public registry:

  • The module must be in a public GitHub repository. (This applies only to the public registry; private registries have no such requirement.)

  • The repository is named terraform-<PROVIDER>-<NAME>.

  • The GitHub repository description becomes the module’s short description, so make it one simple sentence.

  • The repository follows the standard module structure, which the registry uses to generate documentation, track resource usage and parse submodules and examples.

  • There is at least one tag in x.y.z form, optionally with a v prefix.

Publishing is then a few clicks. On the registry website choose Upload in the top navigation; sign in with GitHub, which asks for access to public repositories only, to repository hooks (so the registry can register a webhook), and to your email address (for module alerts). Select the repository from the list of those matching the naming convention and choose Publish Module. The module appears within seconds. New versions are published by pushing new tags. If a version fails to appear, Resync Module under Manage Module resyncs all versions of the module; use it only when a version is missing.

The registry extracts the module name, provider, documentation, inputs and outputs and dependencies from the source and shows them in its UI and API, and the same for any submodules and examples in the repository. Owners can remove a single version or all versions from the Manage Module menu, but the documentation advises against removing content unless it has a critical flaw: other people may depend on it.

Before publishing a module publicly, check that nothing in the repository identifies your organisation’s real infrastructure: example values, default registry hostnames in examples/, tag names, and comments all become public.

Publishing to the HCP Terraform private registry

The HCP Terraform (and Terraform Enterprise) private registry hosts modules your organisation shares internally. Setup requirements:

  • You need to be on the owners team, or a team with the Manage private registry permission.

  • A VCS provider connection must already be configured, and the HCP Terraform user needs admin access to the repository so the registry can create webhooks. For GitLab, the repository must sit directly in the main organisation or group, not in a subgroup.

  • Module source code in subfolders must follow the standard module structure.

  • In a repository that holds several modules, set a release tag prefix (for example app-), so each module’s versions are published separately.

There are two publishing types:

Type Behaviour

Tag-based

Release tags must be semantic versions in x.y.z form, optionally prefixed with v; the registry ignores tags that do not match, and the repository needs at least one matching tag. To release, push a new tag.

Branch-based

You name an existing branch and a module version; no matching tag is required. To release, choose Publish New Version, pick a commit SHA and assign a new version (existing versions cannot be reused). Only branch-based modules support the registry’s integrated testing.

In the UI, go to Registry, choose Publish then Module, select the VCS connection and repository, choose the publishing type and fill its fields, set the module name and provider name (which form the module’s address) and click Publish module. Optionally the module can be added to the no-code provisioning allowlist (Standard and Premium editions), which lets users deploy it from a form without writing configuration. The API can also publish modules, including modules with no VCS source, which the UI cannot do. Deleting a VCS tag does not remove the version from the registry; deletion happens from the module details page, is permanent, and can remove one version, all versions for a provider, or all providers and versions for a name.

Callers reference a private module with the hostname in the source address, for HCP Terraform app.terraform.io, and authenticate with terraform login (or a manually configured credentials entry in the CLI configuration) for that hostname. For Terraform Enterprise the hostname is the instance’s, or the generic hostname localterraform.com for configurations that must run unchanged across instances (CLI 1.4.0 and later).

module "network" {
  source  = "app.terraform.io/example_corp/bookshelf-network/docker"
  version = "1.2.0"
}

HCP Terraform workspaces, run tasks and no-code modules are discussed in HCP Terraform and Terraform Enterprise.

The module registry protocol and self-hosted registries

The public registry, the HCP Terraform registry and other registries all speak the same module registry protocol: the CLI performs service discovery against the hostname of a module address (hostname/namespace/name/system, with the hostname defaulting to registry.terraform.io), finds the base URL for the modules.v1 service, lists versions with GET :namespace/:name/:system/versions and downloads a chosen version with GET :namespace/:name/:system/:version/download, which answers 204 No Content and an X-Terraform-Get header that holds a normal module source (a Git URL, an archive and so on, but not another registry address). Anyone can implement it to host modules under a hostname they control. Artifact repositories such as JFrog Artifactory implement this protocol for modules, with local repositories for your modules, remote repositories that proxy the public registry and virtual repositories that combine both; modules are published with the vendor’s tooling and consumed with a source address containing the Artifactory host and repository key. The vendor documents that part, not HashiCorp.

Distributing through Git only

For many teams a registry is more infrastructure than they need. Git alone provides storage, access control and versions (tags):

  • Use a git:: source with a ?ref= of a tag, never of a branch: a branch silently changes behaviour on the next terraform init -upgrade.

  • The repository still follows the standard structure and keeps examples/ and a changelog.

  • A subdirectory of a repository can be addressed with a double slash in the source, which is how a monorepo is consumed without a registry (see Modules).

  • Git sources do not support the version argument, so Dependabot and Renovate must be configured for Git references.

What is lost compared with a registry: no generated documentation pages, no discovery, no semantic constraints (~> 1.2), and no central place to see which versions exist.

The OpenTofu registry

OpenTofu has its own registry, and modules are added by opening a Submit new Module issue in the registry repository using the issue form linked from its README. The OpenTofu team reviews the submission and approves or denies it. The repository’s contribution guide says submissions must be made through the issue form UI: pull requests that add registry data directly, or issues created via the GitHub CLI, the API or other tooling, are not processed. Check the repository’s policy and contributing documents for the technical inclusion rules. A module published to the Terraform registry is not automatically available to OpenTofu users from the OpenTofu registry, and vice versa.

Continuous integration for a module repository

A module repository needs a pipeline that formats, validates, tests and documents on every pull request, and that keeps releases honest. The pipeline below runs the checks that need no cloud credentials. It uses hashicorp/setup-terraform, whose terraform_version input pins a CLI version (or a constraint) and whose terraform_wrapper input turns off the stdout-capturing wrapper when later steps do not need it.

# .github/workflows/module.yml
name: module
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        terraform_version: ["1.15", "1.17"]
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: ${{ matrix.terraform_version }}
          terraform_wrapper: false
      - name: Format
        run: terraform fmt -check -recursive
      - name: Validate the module and its examples
        run: |
          for dir in . examples/*/; do
            terraform -chdir="$dir" init -backend=false -input=false
            terraform -chdir="$dir" validate
          done
      - name: Test
        run: terraform test
      - name: README is up to date
        run: |
          docker run --rm --volume "$(pwd):/terraform-docs" -u "$(id -u)" \
            quay.io/terraform-docs/terraform-docs:0.24.0 \
            markdown table --output-file README.md --output-mode inject /terraform-docs
          git diff --exit-code README.md

A second workflow can create the GitHub release when a version tag is pushed:

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ["v[0-9]+.[0-9]+.[0-9]+"]

permissions:
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: Create the GitHub release with generated notes
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh release create "$GITHUB_REF_NAME" --generate-notes

The registry release itself is the tag; the workflow above only adds release notes. The action version numbers (actions/checkout@v7, hashicorp/setup-terraform@v4) and the terraform-docs image tag are examples; pin the current releases (ideally by commit SHA) and let Dependabot update them. A two-version matrix, as shown, is how a module verifies the oldest Terraform it claims to support in required_version and the newest release; add an OpenTofu job the same way if you support both. More on linting, security scanning and test pyramids is in Code Quality and Security Scanning and CI/CD and Automation.

Module composition diagram

The diagram shows the Bookshelf envs/dev root module and the information that flows between the child modules. Every arrow is an expression in the root module (an output used as an argument), and Terraform derives the creation order from them.

flowchart TD subgraph Root["envs/dev (root module)"] direction TB NET["module.network"] DB["module.database"] CAT["module.catalog_api"] REV["module.reviews_api"] WEB["module.web"] end NET -->|"network_names backend"| DB NET -->|"network_names frontend and backend"| CAT NET -->|"network_names frontend and backend"| REV NET -->|"network_names frontend"| WEB DB -->|"connection_url (sensitive)"| CAT DB -->|"connection_url (sensitive)"| REV CAT -->|"internal_urls"| WEB REV -->|"internal_urls"| WEB subgraph Modules["Reusable modules (versioned)"] direction LR MN["network v1.x"] MD["database v1.x"] MS["service v1.x"] end NET -. instance of .-> MN DB -. instance of .-> MD CAT -. instance of .-> MS REV -. instance of .-> MS WEB -. instance of .-> MS

The release path of a module, from change to consumer, is a second small flow:

flowchart LR A["Pull request: change variables or resources"] --> B["CI: fmt, validate, test, terraform-docs check"] B --> C["Merge to main"] C --> D["Choose SemVer bump and add moved or deprecated entries"] D --> E["git tag vX.Y.Z and push"] E --> F["Webhook notifies the registry"] F --> G["New version listed"] G --> H["Callers raise version constraint; terraform init -upgrade; plan"]

Terraform and OpenTofu differences

Topic Terraform OpenTofu

Standard module structure

README, LICENSE, main.tf, variables.tf, outputs.tf, modules/, examples/.

The same layout and rules are documented.

Dual-tool modules

Reads .tf and .tf.json files.

A .tofu file takes precedence over a .tf file of the same name in the same directory, so a module can ship a foo.tf for Terraform and a foo.tofu for OpenTofu. The OpenTofu documentation recommends this for module authors who want to support both tools.

Registry

Public Terraform Registry (public GitHub repository, terraform-<PROVIDER>-<NAME>, tags) and HCP Terraform / Terraform Enterprise private registries.

OpenTofu Registry, populated through reviewed submissions; other registries that implement the module registry protocol work with both tools.

deprecated on variables and outputs

Terraform 1.15.

OpenTofu 1.10.

moved blocks

Terraform 1.1.

Supported by OpenTofu; see the OpenTofu documentation for refactoring.

Cross-variable validation

Terraform 1.9.

OpenTofu 1.9.

Module sources

Local, registry, Git, HTTP, S3, GCS.

The same, plus OCI registries as module sources (see OpenTofu).

Command names

terraform test, terraform init.

tofu test, tofu init.

If a module must work with both tools, test it with both in CI, avoid features that only one supports, and use .tofu files only for the differing parts. See OpenTofu for the wider comparison.

What changed recently

  • Terraform 1.1: moved blocks can record the refactoring of module addresses, replacing most uses of terraform state mv in module upgrades.

  • Terraform 1.3 (2022-09-21): object type constraints can declare optional attributes with defaults, the basis of the resources variables above.

  • Terraform 1.6: the native terraform test framework, used to test modules from tests/.

  • Terraform 1.9 and OpenTofu 1.9: variable validation conditions can reference other variables and data sources.

  • Terraform 1.15 and OpenTofu 1.10: the deprecated argument on variables and outputs lets module authors announce removals; Terraform 1.15 adds ignore_nested_deprecations to the module block.

  • Terraform 1.15: constant variables (const = true) and locals can be used in the source and version arguments of a module block, which changes how thin wrappers around a module’s location are written; see Modules.

  • Terraform 1.16: import blocks can be used inside modules, so a module can own the adoption of existing objects; see Import and Query.

  • Terraform 1.17: variables and locals can be used in provider requirements, useful for wrappers that choose provider versions; see Providers.

  • HCP Terraform: private registry modules can be offered as no-code modules, and branch-based publishing supports the registry’s integrated testing.

Consult the changelogs for the exact release in which each feature appears, since this page records the versions that the related pages of this section state.

Where the books are outdated or wrong

One of the reference books for this section states that Terraform has "no built-in mechanism for module dependencies". That is an error and has not been true for a long time: since Terraform 0.13 depends_on can be set on a module block (as can count and for_each), and in practice most dependencies between modules are expressed implicitly, by passing one module’s output to another’s input, exactly as the Bookshelf composition does. Use depends_on on a module only for hidden dependencies; it makes every resource inside depend on the listed objects and can force unnecessary replacements, so prefer an output reference.

Older descriptions of module development also predate these changes:

  • Modules written with provider blocks inside them, or with required_providers that pin one exact version, work but cannot be used with count, for_each or depends_on (provider blocks) or fight with the caller’s constraints (exact pins). Current guidance is a minimum version and provider configurations passed in from the caller.

  • The optional() modifier with defaults, validation blocks that mention other variables, the deprecated attribute and moved blocks did not exist in the older Terraform releases covered by the books, so code written for older releases handles optional settings with lookup() over a map(any), validates only in the calling code, or renames resources with terraform state mv. All three have better replacements today.

  • Terraform Registry procedures changed over time. Follow the current publishing page rather than a book’s screenshots.

Pitfalls

  • Publishing a module whose examples/ use relative source paths: users copy the example and it no longer works.

  • Putting a provider block inside a reusable module: removing the module call strands the resources.

  • Pinning a shared module’s provider with = or ~> on a minor version: callers that need a newer provider cannot resolve their dependency graph.

  • Adding a required variable in a minor release: it is a breaking change even though it feels additive.

  • Renaming a resource in a minor release without a moved block: callers' next plan destroys and recreates it.

  • Removing historical moved blocks: callers who skip releases lose their state mapping.

  • Using latest or floating tags as defaults for images or module versions: plans stop being reproducible.

  • Documenting the interface by hand: it drifts. Generate it.

  • Letting the registry see a tag before the code is ready: tags are published within a minute and cannot be quietly rewritten. Tag after the pull request is merged and CI passes.

  • A monorepo of modules on the public registry: it requires one repository per module; use a private registry with tag prefixes, or split the repositories.

  • Sensitive values in module outputs without sensitive = true: Terraform refuses to apply because the value comes from a sensitive source, and callers see a confusing error.

  • Hard-coding addresses or names inside a module so that two copies of it collide: every named object in a module that can be instantiated twice must take part of its name from an input.