Skip to main content
RunBook Academy

TerraformXXVII · Enterprise Scale: Multi-Team, Multi-AccountProduction Terraform

Enterprise Module Ecosystem

Advanced⏱ ~14 minbash

What you'll learn

  • Distinguish the public Terraform Registry from an internal private registry
  • Describe the module contract: variables, outputs, examples, README, and CI-tested consumers
  • Configure governance: CODEOWNERS, required reviews, and version pinning
  • Set up a private module registry (Terraform Cloud or a self-hosted alternative)
  • Identify the failure modes of an unmanaged module ecosystem

Prerequisites

None — start here.

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.

Modules are the supply chain of a Terraform estate. A module that breaks breaks every consumer that depends on it. A malicious module is a supply-chain attack against every consumer that consumes it. The lesson is about running the module ecosystem with the same discipline as a software package ecosystem: a registry, a contract, governance, and version pinning.

What a “module ecosystem” actually is

An internal module ecosystem is the set of internal modules an organisation publishes for its own consumption, plus the governance around them. At minimum:

  ┌──────────────────────────────────────────────────────────┐
  │  Registry                                               │
  │  ─────────                                               │
  │  Where modules are published and discovered. Either      │
  │  Terraform Cloud's private registry, an OSS registry    │
  │  (e.g. terraporium on a self-hosted git server), or a   │
  │  versioned Git server with semver tags.                  │
  │                                                          │
  │  Contract                                               │
  │  ──────────                                              │
  │  The shape every module must satisfy: declared          │
  │  variables, declared outputs, an example, a README,     │
  │  CI tests against consumers.                             │
  │                                                          │
  │  Governance                                             │
  │  ───────────                                             │
  │  Who can publish, who can review, who can tag a         │
  │  release. CODEOWNERS on the module repo, required       │
  │  reviews on releases, branch protection on main.         │
  │                                                          │
  │  Version pinning                                         │
  │  ──────────────────                                      │
  │  Consumers pin to a specific version. The default       │
  │  branch is never consumed. Tags are immutable.           │
  └──────────────────────────────────────────────────────────┘

Public vs private registries

The public Terraform Registry hosts thousands of community modules: VPCs, EKS clusters, RDS instances, S3 buckets, the list goes on. Public modules are convenient and they are not safe to consume in production without review. The reasons:

  1. The author is unknown to your organisation.
     A malicious change to a widely-used public module
     affects every consumer.

  2. The version may be abandoned.
     A 1.0.0 release that has not been updated in two
     years is a 1.0.0 release that will not be patched.

  3. The module's variables may not match your standards.
     A public module that takes an unconstrained string
     for a CIDR block has no validation. Your internal
     module enforces `cidrsubnet()` and validates the
     block is private.

  4. The module's defaults may not match your policies.
     A public module that defaults to public S3 buckets
     and unencrypted EBS volumes is a public module your
     policy forbids.

The private registry is the same shape — module source, version constraint, examples — but the modules are owned and reviewed by your organisation. The supply chain is closed.

Where the private registry lives

Three viable patterns, in order of operational cost:

Pattern A: Terraform Cloud or HCP Terraform. The managed private registry. Terraform Cloud hosts the module source, generates documentation from the variables and outputs, and tracks which workspaces consume which versions. Cost: per-resource pricing; some teams object on principle.

Pattern B: self-hosted registry (Terraportium, cluj, or a custom service). The OSS alternatives implement the same shape but require operational care: a registry server, a database, a backup. Cost: engineering time.

Pattern C: Git server with semver tags. No registry at all. Modules are git repositories; consumers consume via git::https://...//...?ref=vX.Y.Z. The “registry” is the git server’s tag listing. Cost: minimal; loses the Terraform Cloud documentation generation and consumer tracking.

For most enterprises in 2026 the choice is Pattern A for organisations already using Terraform Cloud, Pattern B for organisations that want OSS, and Pattern C for the smallest estates.

The module contract

Every internal module published to the registry must satisfy a contract. The contract is enforced by CI and audited periodically:

  ┌──────────────────────────────────────────────────────────┐
  │  variables.tf                                            │
  │  ──────────────                                          │
  │  Every input variable has a type, a description, and     │
  │  a validation block where the type allows it.            │
  │                                                          │
  │  outputs.tf                                              │
  │  ────────────                                            │
  │  Every output that consumers may need is declared,      │
  │  with a description. No undeclared outputs leak from    │
  │  nested modules.                                         │
  │                                                          │
  │  versions.tf                                             │
  │  ─────────────                                           │
  │  required_version and required_providers are declared    │
  │  with conservative lower bounds.                         │
  │                                                          │
  │  examples/                                               │
  │  ──────────                                              │
  │  At least one example module that consumes the module    │
  │  and exercises its full public surface.                  │
  │                                                          │
  │  README.md                                               │
  │  ────────────                                            │
  │  A description, a usage example, the inputs and         │
  │  outputs documented, the policy requirements (e.g.       │
  │  "must be private, must be encrypted"), and the          │
  │  consumer's responsibilities.                            │
  │                                                          │
  │  CHANGELOG.md or release notes                           │
  │  ──────────────────────────────                          │
  │  Every release has a note explaining the bump level      │
  │  (major / minor / patch) and the migration steps for    │
  │  major bumps.                                            │
  │                                                          │
  │  CI                                                      │
  │  ──                                                      │
  │  terraform fmt -check, terraform validate, and an        │
  │  integration job that runs `terraform plan` against       │
  │  every consumer stack.                                   │
  └──────────────────────────────────────────────────────────┘

The contract is what makes the difference between “a module on GitHub” and “an internal module the platform team supports”. Modules that do not satisfy the contract are not published; they remain personal utilities in a developer’s local working directory.

The module contract in HCL

# modules/network/vpc/variables.tf

variable "cidr_block" {
  type        = string
  description = "CIDR block for the VPC. Must be a private range (RFC1918)."

  validation {
    condition     = can(cidrnetmask(var.cidr_block)) && tonumber(cidrnetmask(var.cidr_block)) <= 16
    error_message = "cidr_block must be a valid CIDR with mask /16 or wider."
  }
}

variable "environment" {
  type        = string
  description = "Environment name; used for tagging and naming."

  validation {
    condition     = contains(["prod", "staging", "dev"], var.environment)
    error_message = "environment must be one of: prod, staging, dev."
  }
}

variable "flow_log_cloudwatch_log_group" {
  type        = string
  description = "CloudWatch log group for VPC flow logs. Must exist in the target account."
}

variable "tags" {
  type        = map(string)
  description = "Additional tags applied to every resource."
  default     = {}
}
# modules/network/vpc/outputs.tf

output "vpc_id" {
  value       = aws_vpc.this.id
  description = "ID of the VPC."
}

output "vpc_cidr_block" {
  value       = aws_vpc.this.cidr_block
  description = "CIDR block of the VPC."
}

output "private_subnet_ids" {
  value       = aws_subnet.private[*].id
  description = "IDs of the private subnets; consumed by ECS, EKS, and RDS modules."
}

The validation blocks are the contract enforcement in HCL. A consumer that violates the contract cannot run terraform plan; the validation fails before any provider API call. The contract is not advisory.

Governance: CODEOWNERS, required reviews, version pinning

The governance has three layers.

Layer 1: CODEOWNERS on the module repo.

# modules/network/vpc/.github/CODEOWNERS
*  @platform-team

Every change to the module requires a review from the platform team. The review is the gate between “a developer wrote a module” and “the module is supported by the platform team”.

Layer 2: branch protection on the default branch.

- Require pull request reviews: 1 approval from CODEOWNERS
- Require status checks to pass: terraform fmt, validate, integration
- Require linear history (no merge commits)
- Restrict who can push to main: only the CI service account

A module is never merged with failing CI. A module is never merged without a CODEOWNERS review. The default branch is sacred.

Layer 3: tag-based releases.

- Tags are created by CI on green builds; never by humans.
- Tags follow semver (MAJOR.MINOR.PATCH).
- Tags are immutable. A tag cannot be deleted or moved.
- Consumers pin to a specific tag in their source argument.

The tag is the publication event. After the tag is created, the module is published to the private registry (if using Terraform Cloud) or is consumable via ?ref=vX.Y.Z (if using a git server).

The supply-chain view

A module’s consumers form a directed graph:

  module.network/vpc ──┬─ stack.prod-eu-network
                       ├─ stack.prod-us-network
                       ├─ stack.nonprod-eu-dev
                       └─ stack.nonprod-us-staging

  module.compute/eks ──┬─ stack.prod-eu-compute
                       ├─ stack.prod-us-compute
                       └─ stack.nonprod-eu-staging

A change to module.network/vpc is a change to every stack that consumes it. The integration test job runs the consumer plan; if any consumer fails, the PR fails. The supply chain is tested.

The pinning rule — consumers pin to a specific tag — is what makes the supply chain safe to evolve. A MAJOR bump in the module requires every consumer to update its source pin. The bump is the migration event; the integration tests verify the migration; the tag is the point at which the new version becomes available.

How to validate the module ecosystem

# READ-ONLY: every consumer pins to a specific tag.
grep -rn 'source.*module.*ref=v' stacks \
  --include='*.tf' | grep -v 'ref=v'
stacks/prod-eu-network/main.tf:21:  source = "git::https://github.com/org/terraform-module-network-vpc.git//vpc"

Any consumer without a ?ref=vX.Y.Z argument is consuming main. The output should be empty.

# READ-ONLY: every internal module repo has CI configured.
gh api repos/org/terraform-module-network-vpc/contents/.github/workflows \
  --jq '.[].name'
test.yml
integration.yml
release.yml

The three workflows exist. If any is missing, the module ecosystem has a gap.

# READ-ONLY: the latest tag of each module is recent.
gh release list --repo org/terraform-module-network-vpc \
  --limit 3 --json tagName,publishedAt
[
  {"tagName": "v3.2.1", "publishedAt": "2026-08-09T10:14:22Z"},
  {"tagName": "v3.2.0", "publishedAt": "2026-07-21T16:01:05Z"},
  {"tagName": "v3.1.0", "publishedAt": "2026-06-15T09:30:11Z"}
]

Recent releases exist. If the latest tag is two years old, the module is abandoned.

Production failure modes

1. Module consumed from main. Symptom: a terraform init produces a different plan without any local code change. Cause: a module source without a ?ref=vX.Y.Z argument. Recovery: pin the source; audit the recent plan diffs; document the pin.

2. Module upgrade breaks a consumer that was not integration-tested. Symptom: a stack plan errors after a minor module upgrade. Cause: the module’s CI ran the unit test but not the integration test. Recovery: add the integration workflow; pin the consumer to a working version; release a patch to the module; re-run the integration tests.

3. Module variables changed without a major bump. Symptom: a consumer’s plan errors after a “minor” upgrade. Cause: the developer changed a variable’s name in a minor release. Recovery: revert the variable change; release a major version; document the migration; review the contribution guide.

4. Module repo has no CODEOWNERS. Symptom: a module PR merges without a platform team review. Cause: the module was contributed by a developer who knew to skip the review. Recovery: add CODEOWNERS; require review in branch protection; add a CI check that the review is from CODEOWNERS.

5. Module registry not in sync with the module repos. Symptom: a module is published to the registry but the ?ref=vX.Y.Z does not match the registry’s version. Cause: the registry was populated manually; the module was tagged separately. Recovery: automate the registry publication from the tag pipeline; audit existing modules.

6. Public module consumed in production without review. Symptom: a production stack consumes terraform-aws-modules/vpc/aws with no internal review. Cause: the contribution guide does not require review for public modules. Recovery: add a CI check that fails on public module sources; require a security review before the source can be added to a stack.

7. Module is abandoned. Symptom: a CVE is disclosed for a provider the module uses; the module has no maintainer. Cause: the module was created for a one-off project and promoted to internal without ongoing ownership. Recovery: fork the module into the internal ecosystem; assign CODEOWNERS; plan the migration of consumers.

Security implications

  • The module supply chain is a real attack surface. A malicious change to a module is a malicious change to every consumer. CODEOWNERS, branch protection, and tag-based releases are the supply-chain controls.
  • Pinning to tags is the supply-chain hygiene rule. A consumer that pulls from main is exposed to every commit, including commits that were never reviewed.
  • The public Terraform Registry is a third-party dependency. Treat it like any third-party: review the module before consumption, track the upstream, and have an exit plan (a fork) in case the upstream is compromised or abandoned.

Performance implications

  • The integration test job runs terraform plan against every consumer. For a module with ten consumers, the job runs ten plans. For a module with fifty consumers, the job runs fifty. The cost is linear in the consumer count.
  • The registry adds a small overhead per terraform init (the registry call to resolve the version constraint). For most estates this is negligible.

Production guidance

  1. Private registry, not public. Every internal module goes through the private registry.
  2. Contract enforced by CI. Validation, examples, README, and integration tests are required, not optional.
  3. CODEOWNERS + branch protection + tag-based releases. The three layers of governance. Any one without the others is incomplete.
  4. Pin to tags. Consumers pin to a specific version. The default branch is never consumed.
  5. Review public modules before consumption. Add the module to the internal ecosystem if it is consumed in more than one stack.

Verification

The module ecosystem is verified when:

  • Every consumer pins to a specific tag (?ref=vX.Y.Z or the registry’s version constraint).
  • Every module repo has CI for terraform fmt, terraform validate, and integration tests.
  • Every module repo has CODEOWNERS and branch protection that requires review from CODEOWNERS.
  • Every release tag is created by CI on green builds.
  • No public module is consumed in production without a documented review.

If any of those five fails, the ecosystem has a hole. Fix it before publishing the next module.

What comes next

This is the final lesson of the Enterprise module. The next module covers operational Terraform: incident response, drift detection at scale, and the runbooks that keep the estate running.

Knowledge check · 7 questions

  1. Q1. What is the role of an internal module registry?

  2. Q2. A public module should be reviewed before it is consumed in production, and promoted into the internal ecosystem if the dependency becomes long-term.

  3. Q3. Which of the following are required for an internal module to satisfy the contract? (Select all that apply.)

  4. Q4. What does version pinning a module mean?

  5. Q5. A team upgrades a public module from 3.4.1 to 3.4.2 and the production plan is broken. What is the correct immediate response?

  6. Q6. Who must approve a PR to an internal module?

  7. Q7. Why should module tags be created by CI rather than by humans?

Passing score: 75%. Answers are checked in this browser.