Terraform Module Versioning and Shared Registries
Version-pinned modules across many repos. The release process, semver discipline, and the breaking-change communication that keeps a shared registry sane.
Key takeaways
- Version-pinned modules across many repos.
- The release process, semver discipline, and the breaking-change communication that keeps a shared registry sane.
On this page
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:
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:
- Modules live in a single repo (
tf-modules/) with subdirectories per module. One repo, one CI pipeline, easy to reason about. - 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." - Consumers pin to exact tags:
hcl.hcl
module "vpc" { source = "git::ssh://git@github.com/company/tf-modules.git//vpc?ref=vpc-v1.2.3" } - A CHANGELOG.md per module documents every release: what changed, breaking changes called out, migration notes.
- 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
validationblock on an existing variable (existing usage might fail validation now). - Renaming an internal resource (consumers using
terraform state mvto 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:
- PR opened. CI runs validate + Terratest. Reviewer assesses changes.
- Reviewer assigns a version-impact label:
breaking,minor,patch. - On merge: a CI step bumps the version per the label (using a tool like
release-pleaseor just a script), updates CHANGELOG.md, and creates the Git tag. - 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:
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:
## 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 — how state evolves alongside module versions
- Terraform modules: patterns that survive real use — broader module design
- Pulumi vs Terraform: why we stayed with Terraform — adjacent tooling decisions
- 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.
Stay Updated
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
LLM Evals That Actually Predict Production Quality
Most LLM eval suites correlate poorly with what real users experience. The eval patterns we run that move with prod metrics — and the ones that lied to us.
Pipeline Observability — Why CI Failures Don't Trigger Alerts (And Should)
Production monitoring catches user-facing issues. CI failures stay invisible until someone notices the merge queue is stuck. The metrics and alerts that make pipelines observable.
More from Infrastructure
Explore more articles in this category
How DNS Works (Explained Simply)
A developer-friendly walk through DNS resolution, record types, TTL, and the caching quirks that cause real production bugs.
Load Balancing Algorithms Explained
A practical tour of the core load balancing algorithms, how each distributes traffic, and when to reach for one over another.
Networking Fundamentals — The Guide for Developers
You don't need a CCNA to ship reliable services, but you do need the core ideas. This is the map: DNS, TCP, TLS, proxies, and CDNs, minus the jargon.
You might have missed
Evergreen posts worth revisiting.