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 |
|---|---|
|
The root module and every nested module should have one. The |
|
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. |
|
The primary entry point. Complex modules may split resources over several files ( |
|
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. |
|
Not mentioned by the structure page, but the common home of the |
|
Nested modules. A nested module with a |
|
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 |
|
A common convention for |
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
typeconstraint turns a wrong value into an error at the module boundary; adescriptionis 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
sensitiveand prefer not to take them at all when the module can generate them (the Bookshelfdatabasemodule 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
sensitivewhen it carries a secret or is derived from one; Terraform insists on this when the value comes from a sensitive input. -
Outputs may carry
preconditionblocks 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_providersinside aterraformblock 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
providersargument of themoduleblock 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
providerblocks 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 themodulecall removes both at once, and the resources can no longer be destroyed. Such legacy modules also cannot be used withcount,for_eachordepends_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 |
|
Name shape |
|
Well-formed CIDR |
|
Pinned image |
|
Range |
|
Whole number |
|
Optional value that must be valid when present |
|
Every element of a collection |
|
Cross-variable (1.9 and later) |
|
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
nullshort-circuits nothing by itself: a condition on an optional variable must handlenullexplicitly, as invar.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
nullof the appropriate type. -
An attribute with a non-null default never becomes
nullin the module: Terraform substitutes the default both when the caller omits the attribute and when the caller sets it explicitly tonull. -
Defaults are applied from the outside in. The default of an outer
optionalis applied first, then the defaults of attributes nested inside it. That is whyoptional(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
optionalattribute without a default isnullwhen absent, so code that reads it must handlenull(var.healthcheck == null ? [] : [var.healthcheck]in adynamicblock). -
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.
Source: Cookiecutter Docs — Usage
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).
Source: terraform-docs — Installation
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.
-
A CI step that regenerates the README and fails when the working tree changes (shown in Continuous integration for a module repository).
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
mapand the resource usesfor_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_newbitsfor every tier plus a uniqueness check onnetnumguarantees that subnets never overlap without writing any CIDR-overlap arithmetic. -
The default for
tiersmakes the module usable with onlyname. Theoptional(bool, false)oninternalmeans most tiers need only anetnum. -
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/completedirectory 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
}
Source: Docker provider — docker_container resource, random provider — random_password resource and Terraform Docs — Type constraints
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 bycountback into a single value ornull. Therandom_passwordresource marksresultas sensitive, and the sensitivity propagates to the outputs. -
The password is still written to the state (the
random_passwordpage says so explicitly, and the container’senvargument 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_urlissensitivebecause it embeds the password; Terraform would refuse to show it in plan output without the marking, andterraform output -raw connection_urlstill prints it on demand. -
The module exports
hostas 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_destroythrough the caller’s workflow or keep the volume outside the module and pass it in (dependency inversion again). -
The
healthcheckletsdocker psand 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.nameon 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. -
dynamicblocks 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_envvariable issensitive, and the combinedlocal.envinherits 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 makesplanunreproducible. 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 bycount, so reducingreplicasremoves 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_urlflows intosecret_env, a sensitive variable, so the sensitivity is consistent from the generator to the container. -
webis on thefrontendtier only. Thebackendtier isinternal = true, so the database is unreachable from outside Docker, andwebcannot reach it at all. This is the whole point of exposing the tiers as separate networks. -
The images for
catalog-apiandreviews-apiuse a placeholder registry (theghcr.io/example-orgnamespace); replace them with your own images. No real credentials appear anywhere; secrets are generated, passed through outputs, or provided withTF_VAR_variables. -
The module calls could use
for_eachto stamp out several services from one map; see Modules forcount,for_eachanddepends_ononmoduleblocks. -
Per-environment differences (replica counts, memory, CIDR blocks) live in
terraform.tfvarsof 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.
Source: Terraform Docs — Command: init
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]
}
Source: Terraform Docs — Tests
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 |
MAJOR |
The plan would destroy and recreate it. With a |
Raise |
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
movedblocks. Usingmovedblocks 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 amovedblock is itself a breaking change, because callers who skipped intermediate versions would plan a deletion. The documentation recommends keeping historicalmovedblocks and chaining them when an object is renamed more than once (atob, thenbtoc), 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. Amovedblock can also target an instance key when the destination module is called withcountorfor_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
deprecatedargument on avariableoroutput(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 withignore_nested_deprecationson themoduleblock (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
movedblocks. -
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.
Source: Terraform Docs — Publish modules
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 |
|
A public GitHub repository named |
HCP Terraform / Terraform Enterprise private registry |
|
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) |
|
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 |
Push a tag. No metadata, no registry UI, no version constraints. |
OpenTofu Registry |
|
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.zform, optionally with avprefix.
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.
Source: Terraform Docs — Publish modules
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 |
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 nextterraform 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
versionargument, 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.
Source: OpenTofu Registry repository
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.
The release path of a module, from change to consumer, is a second small flow:
Source: Terraform Docs — Publish modules
Terraform and OpenTofu differences
| Topic | Terraform | OpenTofu |
|---|---|---|
Standard module structure |
|
The same layout and rules are documented. |
Dual-tool modules |
Reads |
A |
Registry |
Public Terraform Registry (public GitHub repository, |
OpenTofu Registry, populated through reviewed submissions; other registries that implement the module registry protocol work with both tools. |
|
Terraform 1.15. |
OpenTofu 1.10. |
|
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 |
|
|
Source: OpenTofu Docs — Files and directory structure, OpenTofu Docs — Standard module structure and OpenTofu Registry repository
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:
movedblocks can record the refactoring of module addresses, replacing most uses ofterraform state mvin module upgrades. -
Terraform 1.3 (2022-09-21): object type constraints can declare optional attributes with defaults, the basis of the
resourcesvariables above. -
Terraform 1.6: the native
terraform testframework, used to test modules fromtests/. -
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
deprecatedargument on variables and outputs lets module authors announce removals; Terraform 1.15 addsignore_nested_deprecationsto themoduleblock. -
Terraform 1.15: constant variables (
const = true) and locals can be used in thesourceandversionarguments of amoduleblock, which changes how thin wrappers around a module’s location are written; see Modules. -
Terraform 1.16:
importblocks 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.
Source: Terraform v1.17 changelog and Terraform v1.15 changelog
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
providerblocks inside them, or withrequired_providersthat pin one exact version, work but cannot be used withcount,for_eachordepends_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,validationblocks that mention other variables, thedeprecatedattribute andmovedblocks did not exist in the older Terraform releases covered by the books, so code written for older releases handles optional settings withlookup()over amap(any), validates only in the calling code, or renames resources withterraform 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 relativesourcepaths: users copy the example and it no longer works. -
Putting a
providerblock 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
movedblock: callers' next plan destroys and recreates it. -
Removing historical
movedblocks: callers who skip releases lose their state mapping. -
Using
latestor 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.