Skip to main content
RunBook Academy

TerraformXX · Supply Chain: Providers and ModulesProduction Terraform

Module Supply Chain

Intermediate⏱ ~14 minbashgit

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

Not yet marked complete on this device.

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:

ModuleSourceLatest versionOwnerLast releasePin in use
networkapp.terraform.io/acme/network/aws4.2.0platform-networks2026-08-04~> 4.0
iamapp.terraform.io/acme/iam/aws2.1.3platform-networks, security2026-07-21~> 2.1
databaseapp.terraform.io/acme/database/aws1.6.0platform-networks, dba2026-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

  1. No CODEOWNERS file. A junior engineer publishes a network change without a security review. The change removes a deny rule on the security group. The fix is to add CODEOWNERS for /modules/network/ and require @acme/security.

  2. Major change shipped as MINOR. A module author renames a variable and bumps 4.1.0 to 4.2.0. The consumer’s ~> 4.0 accepts the upgrade. The next plan is a flood of replacements. The fix is to require a BREAKING: line in the CHANGELOG for any PR that introduces a MAJOR bump; the CI rejects the tag without it.

  3. Release without a tag. The CI pipeline publishes the latest commit on main to the registry. The consumer cannot pin to “what worked yesterday”. The fix is to require a signed tag before the registry publish.

  4. Consumer pins to no version. A new root module omits version = in the module block. terraform init accepts whatever is current. The next release silently changes the consumer’s plan. The fix is a lint rule that requires version = on every module block in production configurations.

  5. 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.

  6. 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 init cycle.
  • 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.tf declaring the Terraform and provider constraints it was tested against.
  • Every internal module repository has a CODEOWNERS file 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

  1. Q1. Where should an internal module live for production consumers?

  2. Q2. A module author renames a variable. Which version bump is correct?

  3. Q3. Internal modules are recorded in `.terraform.lock.hcl` the same way providers are.

  4. Q4. What is the role of CODEOWNERS in the module supply chain?

  5. Q5. Which of the following belong in a healthy module release cadence? (Select all that apply.)

  6. Q6. A consumer wants to receive PATCH-level fixes but opt in to MINOR features deliberately. Which `version =` is right?

  7. 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.