# Terraform Module Versioning and Shared Registries

URL: https://www.devopsness.com/blog/terraform-module-versioning-shared-registries
Author: Kiril Urbonas
Published: 2026-05-30
Updated: 2026-05-30
Categories: Infrastructure
Tags: Terraform, Iac, Versioning, Modules, Infrastructure

Summary: Version-pinned modules across many repos. The release process, semver discipline, and the breaking-change communication that keeps a shared registry sane.

---
# Terraform Module Versioning and Shared Registries

The first version of any "shared Terraform module" lives in one repo, sourced by Git URL with a branch reference. Six months later there are ten consumers, three of them are broken by recent module changes, and nobody's sure what version anyone is on. The fix is real version management: semver, a registry, and discipline about breaking changes. This post is the model we run.

## Why naked Git references break

When module consumers source by branch:

```hcl
module "vpc" {
  source = "git::ssh://git@github.com/company/tf-modules.git//vpc?ref=main"
}
```

Every `terraform init` pulls the current main. The module owner ships a change; one consumer's next apply silently inherits it. Sometimes that's fine. Often it isn't.

We've had:
- A module owner renamed an output variable; three consumers broke at next apply.
- A module added a required new variable; four consumers' next apply errored.
- A module's resource was renamed; consumers' state files now drift from the module's intent.

Each of these would have been avoidable with version pinning.

## The model

The shape we run:

1. **Modules live in a single repo** (`tf-modules/`) with subdirectories per module. One repo, one CI pipeline, easy to reason about.
2. **Tagged releases follow semver**: `vpc-v1.2.3`, `eks-cluster-v3.0.0`. Tags are the source of truth for "version X of module Y."
3. **Consumers pin to exact tags**:
   ```hcl
   module "vpc" {
     source = "git::ssh://git@github.com/company/tf-modules.git//vpc?ref=vpc-v1.2.3"
   }
   ```
4. **A CHANGELOG.md per module** documents every release: what changed, breaking changes called out, migration notes.
5. **Module CI** runs Terraform validate + a small Terratest suite on every PR. PRs to module code can't merge if validation fails.

That's the whole system. No private registry server, no fancy tooling. Git tags + discipline.

## Semver for modules, applied

Standard semver: MAJOR.MINOR.PATCH.

- **MAJOR**: incompatible changes. Renaming variables, removing outputs, changing default values that affect resource shape, requiring a Terraform version bump.
- **MINOR**: new features that don't break existing consumers. Adding optional variables, adding outputs, supporting new use cases.
- **PATCH**: bug fixes that don't change the interface. Fixing a typo in a tag value, fixing a logic bug in a conditional.

The hard part: deciding what's "breaking." Some changes look minor and aren't:

- Changing a default value (some consumers depend on the default; their behavior changes).
- Adding a `validation` block on an existing variable (existing usage might fail validation now).
- Renaming an internal resource (consumers using `terraform state mv` to manage drift might be affected).

When in doubt, treat it as breaking. The cost of an unnecessary major version is low; the cost of breaking consumers silently is high.

## The release process

A module PR's full lifecycle:

1. PR opened. CI runs validate + Terratest. Reviewer assesses changes.
2. Reviewer assigns a version-impact label: `breaking`, `minor`, `patch`.
3. On merge: a CI step bumps the version per the label (using a tool like `release-please` or just a script), updates CHANGELOG.md, and creates the Git tag.
4. The tag is the released version. Consumers can pin to it.

The version-impact label is the integrity check. We require reviewer agreement on the label — the author can propose `minor`, but the reviewer confirms whether the change is actually breaking.

## Consumer-side pinning

Each consumer of a module pins to an exact version:

```hcl
module "vpc" {
  source = "git::ssh://git@github.com/company/tf-modules.git//vpc?ref=vpc-v1.2.3"

  cidr_block = "10.0.0.0/16"
  azs        = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
```

When the module ships v1.3.0 with a new feature, the consumer doesn't get it automatically. They have to update the `ref` and run `terraform plan` to see what changes. This is deliberate — explicit upgrades are the point.

Tools like `dependabot` (which now supports Terraform) can open PRs against the consumer repos when new module versions ship. We use this for minor/patch updates (auto-suggested PR; reviewed and merged). Major version updates require human consideration.

## Breaking-change communication

For breaking releases, the CHANGELOG.md entry includes a "Migration" section:

```markdown
## v3.0.0 - 2026-04-15

### Breaking changes
- Renamed `enable_logs` variable to `enable_flow_logs` for clarity.
- Removed deprecated `legacy_subnet_mode` variable.

### Migration
- Replace `enable_logs = true` with `enable_flow_logs = true` in your consumer.
- If you were using `legacy_subnet_mode`, set `subnet_strategy = "split-az"` to get equivalent behavior.

### New features
- Added support for IPv6 dual-stack mode (`enable_ipv6 = true`).
```

Major releases also trigger a notification to a Slack channel where consumers are subscribed. The notification includes the migration guide link.

We don't force consumers to upgrade on a schedule. They upgrade when they're ready. Older versions of the module remain installable indefinitely — Git tags never go away.

## Avoiding upgrade debt

Old versions remaining usable forever sounds great but accumulates debt. After a year, you have consumers spread across 5+ major versions of the same module. Security fixes only land on new versions; old ones stay vulnerable.

Mitigations:

- **Periodic upgrade campaigns.** Quarterly, we list consumers on old major versions and work with the owning teams to upgrade. Doesn't have to be all at once; just a steady process.
- **End-of-life policy.** Major versions older than 18 months are marked end-of-life. New security advisories get patched on supported versions only.
- **Compatibility shims for one major version.** When we drop a feature, we keep an deprecation path for one major version cycle — log a warning, accept the old syntax, then remove in the version after. Gives consumers time without forcing.

## What we don't do

A few things that look reasonable for some teams and not for us:

**Run a Terraform Cloud / Terraform Enterprise registry.** Yes, it's nice — has a UI, semver enforcement. Costs money, adds another moving piece. For our scale (one org, ~20 modules, ~40 consumer repos), Git tags + CHANGELOG.md is enough.

**Floating version ranges** (`>=1.0.0`). Terraform allows them. We never use them. The implicit upgrade is exactly the problem we're avoiding.

**Per-module Git repos.** A repo per module is one extreme; a monorepo for all modules is the other. We use the monorepo — one CI pipeline, one set of conventions, easier cross-module changes. The downside (heavier merge contention) hasn't been a problem at our scale.

**Auto-bumping based on commit message conventions.** Tools like commitlint + conventional commits can derive the version automatically. We tried it; the manual label-on-PR step turned out to be more reliable.

## What we measure

Operational metrics for the module ecosystem:

- **Versions in use per module.** Counted across all consumer repos. Surfaces fragmentation.
- **Time-to-upgrade for new versions.** When v2.0.0 ships, how long before 80% of consumers are on it? Slow upgrades signal documentation or migration-path problems.
- **CI pass rate for module PRs.** Catches when the test suite gets too fragile.

## Common mistakes

**Skipping the version label on a PR.** "It's a small change." Then a consumer breaks. The label discipline only works if applied universally.

**Adding required variables in a minor release.** A new required variable breaks every consumer who hasn't set it. Required variables = major version bump. (Or: add as optional with a default, then a later major release removes the default if you must.)

**Combining breaking and non-breaking changes in one release.** Bigger blast radius; harder migration. One PR, one logical change.

**Letting the CHANGELOG drift.** Without the changelog, consumers don't know what changed between versions. Make it part of the merge gate.

## What to read next

- [Terraform state management strategies](/blog/terraform-state-management-strategies) — how state evolves alongside module versions
- [Terraform modules: patterns that survive real use](/blog/terraform-modules-best-practices-building-reusable-infrastructure) — broader module design
- [Pulumi vs Terraform: why we stayed with Terraform](/blog/pulumi-vs-terraform-deep-dive-choosing-right-iac-tool) — adjacent tooling decisions
- [Infrastructure testing strategies — validating IaC](/blog/infrastructure-testing-strategies-validating-iac) — module CI

Module versioning is one of those problems that looks bureaucratic and turns out to be operational reliability. The discipline takes a couple of months to land; afterward it's invisible, which is the goal. Modules ship; consumers upgrade when ready; breaking changes are visible and signposted.

