How an unversioned module breaks production
Here is a familiar story. A platform engineer improves the database module: a new default, a renamed variable, a tidier layout. They merge to main. The next time anyone runs a plan in any environment that references the module by branch, the plan changes. In the best case it is noisy. In the worst case a stateful resource is flagged for replacement and the person applying does not notice.
Terraform module versioning prevents this. A module becomes a dependency with a number, and consumers choose when to move. It is a small discipline with an outsized effect on safety, and it sits at the centre of the reusable modules we deliver within our Terraform and infrastructure as code service.
What good looks like
- Every module release has a semantic version, a tag and a changelog entry.
- Production stacks pin to specific versions.
- Upgrades arrive as small pull requests whose plans can be read.
- Breaking changes are rare, announced and accompanied by migration notes.
- Anyone can find which modules exist, what they do and which version is current.
Distribution options
| Option | How consumers reference it | Strengths | Limits |
|---|---|---|---|
| Git tags | git::https://...?ref=v1.4.0 | No extra service, simple access via your Git host | No version ranges, less discoverable |
| Private registry (HCP Terraform or similar) | app.terraform.io/org/name/aws with version = "~> 1.4" | Constraint syntax, catalogue, access control | Another service and, for hosted options, a cost |
| Artifact store (S3, Artifactory) | Archive or registry endpoint | Fits existing artifact policy | More plumbing |
Terraform’s registry protocol is open, so a self-hosted registry is also possible, but running one rarely beats Git tags until you have many consumers. We often start teams on Git tags and graduate them. Check current product details and pricing for hosted registries before committing, as they change.
How it works in practice
Consuming a pinned module
With Git:
module "database" {
source = "git::https://github.com/example-org/terraform-aws-database.git?ref=v2.3.1"
name = "orders"
instance_class = "db.m6g.large"
}
With a registry, a version constraint lets patch updates flow while blocking breaking changes:
module "database" {
source = "app.terraform.io/example-org/database/aws"
version = "~> 2.3"
name = "orders"
}
The ~> 2.3 constraint accepts 2.3 and later 2.x releases but never 3.0.
Automating the release
People forget to tag. A release tool can derive the next version from conventional commit messages, create the tag, and publish a changelog. A typical flow: pull request merged with a message such as feat: add backup retention input, the release tool opens a release pull request, and merging that tags v2.4.0. Tools like release-please and semantic-release do this. The module’s documentation is regenerated at the same time, as covered in module documentation.
Keeping consumers current
Pinning only helps if upgrades actually happen. Renovate or Dependabot can open a pull request when a new module version appears. The pull request runs a plan through your Terraform pipeline, so the effect is visible before merge.
Declaring what a module needs
A module should state the Terraform and provider versions it supports, so a bad combination fails at init rather than half-way through an apply:
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 6.0"
}
}
}
Modules use ranges like this, while the root configuration that consumes them pins the exact provider through its lock file. That split lets many modules coexist without fighting over one precise version.
Deprecating instead of deleting
When an input has to go, we keep it for at least one minor release, mark its description as deprecated, and add a validation or a warning in the changelog. Callers get time to move, and the eventual major version removes something nobody uses any more. Compare this with a surprise rename, which fails every consumer on the same day.
Decisions and trade-offs
Monorepo or repo per module. One repository per module gives clean tags per module. A monorepo is easier to refactor but needs path-based tagging, which Git sources handle awkwardly. We pick per organisation size.
Strict or loose pins. Exact pins are safest and noisiest. Pessimistic constraints on a registry reduce noise but can pull in surprises. Production gets tighter pins.
How often to break. Every major version is a migration for every consumer. We favour additive changes and deprecation periods, so majors are rare.
When not to use a registry. Under a dozen modules and a single team, a registry is overhead. Tags and a good README are enough.
Failure modes we avoid
- Moving tags. Re-pointing an existing tag silently changes what consumers get. Tags are immutable.
- Unannounced breaking changes in a minor version. We review diffs of variables and outputs before release.
- Orphaned versions. Old majors with no owner. We state a support window for each major.
- Provider constraints that conflict. A module requiring a different provider range from its callers causes init errors. See provider version management for how we align them.
What a release checklist looks like
Before a tag is cut, someone confirms a short list: the example still plans cleanly, the generated documentation is current, the changelog names every changed input and output, and the diff contains no change of default that is not called out. For stateful modules, a reviewer also runs a plan against a copy of an existing stack to see whether any resource would be replaced. A few minutes spent here costs far less than an unplanned replacement in production.
Delivery and handover
You get the versioning policy, the release automation, the registry or tag layout, and a guide to publishing a new version. Existing consumers are moved onto pinned versions first, because that is where the safety comes from.
How to tell it is working
No plan changes unless a pull request changed it. You can answer “which version of the network module is production on?” in seconds. Rollbacks are a one-line revert. To set this up on your modules, contact us.