TerraformXX · Supply Chain: Providers and ModulesProduction Terraform
Module Supply Chain
What you'll learn
- Choose the right module source for an internal module: git ref, internal registry, or local path
- Apply semantic versioning to module tags and pin consumers to a version range
- Configure CODEOWNERS so that changes to a module require review by the right team
- Define a release cadence that balances agility against consumer blast radius
- Distinguish between a public-registry module and an internal-registry module in the lock contract
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A Terraform module without a release process is a module with unannounced changes. The consumer wakes up to a plan that proposes to recreate the database. The consumer does not know why. The consumer does not know who changed what.
For an internal module, the failure mode is sharper than for a public one. The consumer is another team in your organisation. The consumer does not have the option to “switch to a different module”; the module is yours. The discipline of the supply chain is what stands between a breaking change and an outage.
The module supply chain at a glance
Source of truth Distribution Consumer
--------------- ------------ --------
github.com/acme/terraform- internal Terraform root module:
modules/network Enterprise registry module "vpc"
├── main.tf (or Terralist, OSS) source = "app.terraform.io/acme/network/aws"
├── variables.tf version = "~> 4.0"
├── outputs.tf
├── versions.tf
├── README.md
└── CHANGELOG.md
CODEOWNERS: @acme/platform-networks @acme/security
tags: v4.0.0, v4.1.0, v4.1.1
Three things make this a supply chain: the source of truth is a single repository, the distribution is a registry the consumer trusts, and the consumer pins to a version.
Choosing the module source
A consumer references a module with a source = and a
version =. The source determines how the module is
fetched; the version determines which release.
module "vpc" {
source = "app.terraform.io/acme/network/aws"
version = "~> 4.0"
}
The source options:
app.terraform.io/acme/network/aws— the Terraform Cloud / Enterprise private registry. The registry stores the module tarball; the consumer downloads it. The registry records download counts and versions; the registry enforces namespace permissions.git::https://github.com/acme/terraform-modules.git//network?ref=v4.1.0— a Git repository, fetched by tag. No registry; the consumer pulls the Git source directly. The lock file records the commit SHA and the tag.github.com/acme/terraform-modules/network/aws— shorthand for the public Terraform Registry. This works for public modules; it does not work for internal ones unless the namespace is yours on the public registry../modules/network— a local path. Useful for development and tests; not appropriate for production consumers because there is no version to pin.
For an internal module, the right choice is the private registry on Terraform Cloud / Enterprise. The registry records who published what, when, and against which tag. The consumer’s plan output names the registry and the version, not a Git ref. The audit is at the registry.
Versioning and pinning
Internal modules follow the same semver discipline as
public ones. The version is MAJOR.MINOR.PATCH. The bump
encodes the contract:
- MAJOR — incompatible change. Renamed variable, removed output, changed default that consumers depend on.
- MINOR — backwards-compatible feature. New variable, new output, new resource.
- PATCH — backwards-compatible fix. Bug fix, documentation, internal refactor.
The consumer pins to a range:
module "vpc" {
source = "app.terraform.io/acme/network/aws"
version = "~> 4.0"
}
~> 4.0 allows 4.0 through 4.x but not 5.0. The consumer
opts in to the next major deliberately. The major bump is
the contract.
The discipline is to ask before every release: does this change the behaviour a consumer depends on? If yes, the answer is MAJOR. If no, the answer is MINOR or PATCH.
The release cadence
The cadence is the policy that decides how often a module releases. Three viable cadences:
- Continuous. Every merged PR triggers a release. The consumer gets features quickly. The consumer also gets bugs quickly. The discipline of CODEOWNERS and CI tests must be tight.
- Weekly. A release is cut every Tuesday. The cadence is predictable. The consumer knows when to plan an upgrade. The minor releases accumulate; the consumer picks them up on their own cadence.
- On-demand. A release is cut when a feature is ready or when a security fix lands. The cadence is irregular. The consumer plans upgrades around announcements.
The default for a stable internal module is weekly. The cadence is predictable, the change set is reviewable in one sitting, and the consumer can plan. Continuous is appropriate for fast-moving modules that few consumers depend on; on-demand is appropriate for security fixes and for major releases.
Mon Tue Wed Thu Fri
| | | | |
| v | | |
PRs ----> tag v4.x.y on main
merge |
to main v
CI: lint, test, validate, plan
|
v
publish to internal registry
|
v
announce in #infra-modules
The release is cut by CI on a tag. The tag is signed. The registry publishes the tarball. The announcement is automated.
CODEOWNERS
CODEOWNERS is a GitHub file (and a GitLab equivalent)
that requires review from the named owners for any PR
that touches matching paths. For an internal module, the
CODEOWNERS file is the gate.
# .github/CODEOWNERS
# Default owners for everything in this repo.
/ @acme/platform-networks
# Networking modules require a security review.
/modules/network/ @acme/platform-networks @acme/security
/modules/iam/ @acme/platform-networks @acme/security
# Database modules require a DBA review.
/modules/database/ @acme/platform-networks @acme/dba
# Documentation and examples are platform-networks only.
/examples/ @acme/platform-networks
*.md @acme/platform-networks
The discipline:
- Every module has at least one owner. A module without an owner is a module that nobody reviews.
- Sensitive modules (network, IAM, KMS, secrets) require a security owner in addition to the platform owner. Two-reviewer approval is the production control.
- The CODEOWNERS file is itself owned by the platform team. Changes to who-can-approve require their own approval.
The CODEOWNERS file is the contract. A PR that touches a module without the matching owner is blocked at merge time. The CI fails fast; the reviewer is the gate, not the post-merge auditor.
The registry contract
The internal registry’s contract with the consumer is the same as the public registry’s: semver tags, a README, a CHANGELOG, signed releases, and a versioning metadata block.
# modules/network/versions.tf
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
aws = {
source = "registry.terraform.io/hashicorp/aws"
version = ">= 5.40.0, < 6.0.0"
}
}
}
The versions.tf file is the consumer’s contract about
which Terraform core and which providers the module was
tested against. The consumer’s terraform init resolves
the constraints; the lock file pins the resolution. If a
module declares core >= 1.9.0 and the consumer pins core
to 1.8.x, the init fails fast.
The lock file and modules
Modules are not recorded in .terraform.lock.hcl — that
file covers providers only. Module pinning is in the
configuration’s version = attribute.
module "vpc" {
source = "app.terraform.io/acme/network/aws"
version = "4.1.0"
}
The consumer’s choice: pin to an exact version
("4.1.0"), pin to a range (“”~> 4.0”), or pin to a Git ref (?ref=v4.1.0`).
For internal modules, the right discipline is pin to a
range with a minimum minor ("~> 4.0"). The range
captures PATCH-level fixes and MINOR features automatically,
because ~> 4.0 names the minor and so lets the minor move;
the consumer opts in to MAJOR changes by editing the
constraint.
A consumer who pins to "4.1.0" exactly gets the audited
version and nothing else. A consumer who pins to no
version gets whatever is current. The range is the
balance.
What the audit produces
A quarterly module audit produces a list:
| Module | Source | Latest version | Owner | Last release | Pin in use |
|---|---|---|---|---|---|
| network | app.terraform.io/acme/network/aws | 4.2.0 | platform-networks | 2026-08-04 | ~> 4.0 |
| iam | app.terraform.io/acme/iam/aws | 2.1.3 | platform-networks, security | 2026-07-21 | ~> 2.1 |
| database | app.terraform.io/acme/database/aws | 1.6.0 | platform-networks, dba | 2026-08-11 | ~> 1.5 |
The audit’s value is the diff against the previous quarter:
> network: 4.1.0 -> 4.2.0 (MINOR, new `flow_log_retention` variable)
> iam: 2.1.2 -> 2.1.3 (PATCH, fixed KMS key policy)
< database: 1.5.4 -> 1.6.0 (MINOR, deprecated `legacy_subnet_ids`)
The diff is the change set. The audit confirms the contract was honoured; the release notes confirm the bump is correct.
Production failure modes
-
No CODEOWNERS file. A junior engineer publishes a network change without a security review. The change removes a
denyrule on the security group. The fix is to add CODEOWNERS for/modules/network/and require@acme/security. -
Major change shipped as MINOR. A module author renames a variable and bumps
4.1.0to4.2.0. The consumer’s~> 4.0accepts the upgrade. The next plan is a flood of replacements. The fix is to require aBREAKING:line in the CHANGELOG for any PR that introduces a MAJOR bump; the CI rejects the tag without it. -
Release without a tag. The CI pipeline publishes the latest commit on
mainto the registry. The consumer cannot pin to “what worked yesterday”. The fix is to require a signed tag before the registry publish. -
Consumer pins to no version. A new root module omits
version =in themoduleblock.terraform initaccepts whatever is current. The next release silently changes the consumer’s plan. The fix is a lint rule that requiresversion =on every module block in production configurations. -
Registry publishes the wrong SHA. A race condition in the CI publish step records the wrong commit SHA against the tag. Consumers’ plans diverge from the audit. The fix is to verify the registry’s recorded SHA matches the tag’s commit SHA after each publish.
-
Release cadence too fast. A module releases on every merge. Consumers cannot keep up. Drift between the module’s pinned version and the latest release grows. The fix is to enforce a weekly release train and to require a PATCH to be back-ported to the previous MINOR for 30 days.
Security implications
- The CODEOWNERS file is the security gate. Without it, any engineer with write access can change a security module without a security review.
- The signed tag is the audit trail. A consumer who needs to verify what they are running can verify the tag against the registry’s recorded SHA.
- The registry’s access controls are the boundary between the platform team (which publishes) and the consumer teams (which consume). A misconfigured registry lets any consumer publish a release; the audit must include a permissions review.
Performance implications
- The registry is a download. The cost is a network
round trip; the cost is amortised over the consumer’s
initcycle. - CODEOWNERS adds reviewer latency to PRs. The cost is measured in hours, not minutes. The benefit is a second pair of eyes on a security-sensitive change.
What comes next
The next lesson covers evaluating a third-party module before adoption: the checklist, the cadence, the approval, and the cost of skipping it.
Verification
- Every internal module has a
versions.tfdeclaring the Terraform and provider constraints it was tested against. - Every internal module repository has a
CODEOWNERSfile with at least one owner. - The CI pipeline rejects releases without a signed semver tag.
- The CI pipeline rejects MINOR or PATCH tags whose CHANGELOG entry mentions a breaking change.
- The internal registry’s permissions are reviewed quarterly.
Knowledge check · 7 questions
Q1. Where should an internal module live for production consumers?
Q2. A module author renames a variable. Which version bump is correct?
Q3. Internal modules are recorded in `.terraform.lock.hcl` the same way providers are.
Q4. What is the role of CODEOWNERS in the module supply chain?
Q5. Which of the following belong in a healthy module release cadence? (Select all that apply.)
Q6. A consumer wants to receive PATCH-level fixes but opt in to MINOR features deliberately. Which `version =` is right?
Q7. An internal `network` module is published without a CODEOWNERS file. A junior engineer merges a PR that opens `0.0.0.0/0` ingress on the production security group. What control would have caught this?
Passing score: 75%. Answers are checked in this browser.