Skip to main content
Cloud DevOps Solutions
Get a quote Talk to an engineer

Terraform / IaC

Versioning Terraform Modules and Setting Up a Private Registry

We give your Terraform modules semantic versions, a changelog and an automated release process, then publish them through a private registry or pinned Git tags. Consumers upgrade on purpose, see what changed, and can roll back by editing one version constraint.

Key takeaways

  • Unversioned modules mean one change can break every environment at the next plan.
  • Semantic versioning only helps if breaking changes genuinely get a major bump, so we enforce that in review.
  • A private registry is optional: Git tags are enough for many teams, and a registry adds discovery and constraint support.
  • Pin modules in production and upgrade through the same pipeline as any other change.
  • Release automation removes the human step where versions get forgotten.

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

OptionHow consumers reference itStrengthsLimits
Git tagsgit::https://...?ref=v1.4.0No extra service, simple access via your Git hostNo version ranges, less discoverable
Private registry (HCP Terraform or similar)app.terraform.io/org/name/aws with version = "~> 1.4"Constraint syntax, catalogue, access controlAnother service and, for hosted options, a cost
Artifact store (S3, Artifactory)Archive or registry endpointFits existing artifact policyMore 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.

References

How we deliver it

  1. 01

    Choose the distribution model

    We compare Git tags, a hosted private registry and an artifact store against your team size and tooling.

  2. 02

    Define the versioning policy

    We agree what counts as a patch, minor or major change for your modules, and write it into a contribution guide.

  3. 03

    Split repositories sensibly

    Modules move into versioned repositories or release paths with clear ownership.

  4. 04

    Automate releases

    Merging to main produces a tag, a changelog entry and a published version through CI.

  5. 05

    Roll out constraints

    Consumers pin versions with sensible constraints, and upgrades arrive as small, reviewed pull requests.

Tools and technologies

TerraformOpenTofuHCP Terraform private registryGitHubGitLabrelease-pleasesemantic-releaseRenovateDependabotterraform-docsArtifactory

Frequently asked questions

Why version Terraform modules?
Without versions, every consumer tracks the latest code, so a module change can alter infrastructure in every environment at once. Versioning lets each stack upgrade when ready, review the plan, and roll back by reverting a version number. It turns shared code from a risk into a managed dependency.
Do we need a private Terraform registry?
Not always. Git tag references such as a source URL with a ref work well for small teams and need no extra service. A registry adds version constraint syntax, a catalogue of modules, and access control. It starts to pay off once many teams consume many modules.
How do we version a Terraform module?
Use semantic versioning: patch for bug fixes, minor for backwards-compatible features, and major for breaking changes such as renamed variables or changed defaults. Create an annotated tag per release, keep a changelog, and automate the process so no one has to remember the steps.
What is a breaking change in a module?
Anything that makes existing callers produce a different or failing plan: removing or renaming a variable, changing a default, renaming an output, or altering resource addresses without a moved block. These warrant a major version and clear upgrade notes.
How should consumers pin module versions?
In production, pin to an exact version or a narrow range, then let a tool like Renovate propose upgrade pull requests. In experimental environments a looser constraint is acceptable. Never reference a branch name in production, because it can change underneath you.

Talk to an engineer about terraform module versioning

Book a free 30-minute call. We review your current setup and tell you honestly whether and how we can help.

Written by Saddique Khan, DevOps / SRE / Cloud Consultant Engineer. Last updated 2026-10-08.