TTerramantle
Get Started Free

Versioning internal Terraform modules properly

Every team's module story follows the same arc, and most of the pain comes from staying on one rung too long. You start by copy-pasting, then you graduate to Git source refs, then eventually to a registry. Each step fixes the problem the last one created and introduces a subtler one. Knowing the arc lets you jump ahead before the pain forces you.

The maturity ladder

Copy-paste. The first VPC config gets duplicated into the second project. Fast, and fine, until the third. Now a fix has to be applied by hand in three places and someone always misses one. There is no version, no shared source, just drift wearing a trenchcoat.

Git source refs. You extract the module to a repo and reference it by Git URL with a ref:

module "vpc" {
  source = "git::https://github.com/acme/terraform-aws-vpc.git?ref=v1.4.0"
}

This is a real improvement and where most teams live for a long time. But a Git ref is not a version constraint. You cannot say ~> 1.4 and get patch updates; you pin an exact ref or you track a branch and pray. Worse, a tag in Git is mutable. Someone can force-push v1.4.0 to point at different code, and every consumer silently gets it on their next init. Immutability here is a social agreement, not a technical fact.

A registry. The source becomes an address with a real version constraint:

module "vpc" {
  source  = "registry.terramantle.dev/acme/vpc/aws"
  version = "~> 1.4"
}

Now ~> 1.4 means what it says, published versions are immutable so 1.4.0 is 1.4.0 forever, and the constraint resolves through the protocol the CLI already speaks. What breaks at this rung is nothing about consumption. It is that you now have to actually decide what a version means, because consumers are pinning against your promises.

What a breaking change actually is

SemVer is easy to recite and hard to apply, because the question "is this breaking" is answered in HCL specifics, not vibes. A breaking change is anything that makes a previously-valid module block invalid or change behaviour. In practice:

Renaming or removing a variable is breaking. Every consumer passing it now errors:

# v1: variable "subnet_count"
# v2: variable "subnet_ids"   <- every caller setting subnet_count breaks

Removing or renaming an output is breaking, because something downstream references it:

# v1: output "vpc_id"
# v2: output "id"   <- module.vpc.vpc_id is now undefined

Changing a default in a way that alters real infrastructure is breaking even though the interface looks identical. Flipping a default from false to true on something like enable_nat_gateway will apply changes nobody asked for.

Tightening a required provider version can be breaking, because a consumer pinned lower now cannot use your module at all.

The ones that are not breaking are the ones people over-think: adding a new optional variable with a default, adding a new output, fixing a bug that made the module do the wrong thing. New optional surface is a minor bump. A genuine bug fix is a patch, even if someone was relying on the broken behaviour, because you cannot version around every misuse.

The honest test is: could a consumer on the previous version run init and plan after this change with no edits and no surprise diff? If yes, it is minor or patch. If no, it is major, and pretending otherwise just moves the breakage to a worse time.

The release workflow

A module release is a handful of disciplines, none of which a registry does for you. These are practices you adopt, not features you buy.

Tag with SemVer, v1.4.0, and let CI publish on tag. Keep a changelog that describes changes in terms of the module's interface, not its internals, because the interface is all consumers see. Run terraform-docs in CI so the input and output tables never drift from the code. Write tests with terraform test for the module's actual behaviour, so a refactor that changes output shape fails before it ships rather than after. None of this is exotic, and all of it is the difference between a version number that means something and one that is just a counter.

The consumer side

Publishing well is half the job. The other half is how consumers move.

Pin with a range, not an exact version. ~> 1.4 gets you patches and minor updates automatically and holds the line at the next major. Exact pins feel safe and are actually a slow-motion problem: they turn every upgrade into a manual PR and let laggards fall years behind. Set an upgrade cadence, even a loose one, so the gap between published and consumed does not silently grow. And when you deprecate a version, say so in a way consumers actually see at init time, not in a wiki page they will never read.

The visibility gap nobody plans for

Here is the rung past a registry that catches teams out. You have published versions, immutable and well-tagged, consumers pinning with sensible ranges. Then you need to make a breaking change to your most-used module, and you realise you have no idea who consumes it.

Git source refs at least showed up in a code search. Once modules resolve through a registry, the consumer relationship lives in a hundred separate state files and CI configs you cannot grep. So upgrades stall, because nobody wants to be the one who breaks an unknown number of downstream workspaces, and blast radius becomes guesswork. The discipline that got you here, immutability and clean versions, is exactly what hid the consumers from view.

This is the case for a registry that tracks the dependency graph across your repos, so before you bump the major you can see every workspace pinned to the module and how they constrain it. Terramantle builds that graph from what gets published and consumed, which turns "who will this break" from a Slack message into a query. That is one section of a longer argument about getting polyrepo versioning to move at monorepo speed, where the graph does more than answer the blast-radius question.

The versioning discipline is worth it regardless. Just plan for the visibility gap before it is the reason a needed upgrade sits untouched for a year.

If you are earlier on the ladder, naming your modules right is the prerequisite, and publishing over OIDC is how the tag-triggered release above actually authenticates.

Last reviewed

Terramantle is a private Terraform and OpenTofu registry, in beta. See pricing or read the FAQ.