Module Versioning and Registries
The idea in one sentence
Section titled “The idea in one sentence”An unpinned module source can silently resolve to a different, newer version the next time someone runs init, so pinning a version constraint is the same discipline you already apply to the provider lock file, just aimed at module code instead of provider code.
Why an unpinned module version is a silent risk
Section titled “Why an unpinned module version is a silent risk”Registry and Git-tag-referenced modules are not frozen in place the way a local relative path is. If a module block has no version constraint, running terraform init -upgrade — or even a fresh init in a CI pipeline that has never run before — can pull in a newer release of that module without anyone reviewing its changelog first. A minor-looking upgrade can rename a variable, change a default, or restructure the resources it creates, and none of that shows up until plan or apply starts behaving differently than it did yesterday.
# unpinned - always resolves to the latest version on init -upgrademodule "vpc" { source = "terraform-aws-modules/vpc/aws"}This is exactly the same failure mode the provider lock file exists to prevent, just one level up: instead of a provider plugin changing out from under you, it is a module’s own internal implementation changing out from under you.
The pessimistic constraint operator: version = ”~> 5.0”
Section titled “The pessimistic constraint operator: version = ”~> 5.0””Pinning a Registry or Git-tag module with a version argument fixes this. The common pattern is the pessimistic constraint operator, ~>:
# pinned - allows 5.x patch and minor upgrades, blocks a 6.0 major bumpmodule "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0"}~> 5.0 means “allow any version greater than or equal to 5.0, up to but not including 6.0.” In other words, it lets Terraform pick up patch and minor releases within the 5.x line — bug fixes and backward-compatible additions — while refusing to silently jump to 6.0, where a major version bump signals the module’s authors are telling you to expect breaking changes. You still choose when to take that jump, and you can read the module’s changelog first.
Private module sources and publishing your own module
Section titled “Private module sources and publishing your own module”Not every module belongs on the public Registry. A module that encodes one company’s specific networking layout, naming conventions, or compliance requirements has no business being published for the world to see. For these, an organization typically uses a private Git repository or a private module registry — HCP Terraform’s private registry, or a self-hosted equivalent — so the module stays versioned and discoverable internally without ever becoming public.
# private Git repositorymodule "internal_networking" {}
# private module registry (e.g. HCP Terraform)module "internal_networking" { source = "app.terraform.io/acme-corp/networking/aws" version = "~> 2.1"}If you do want to publish a module publicly, the Terraform Registry expects a specific repository naming convention: terraform-<PROVIDER>-<NAME>, for example terraform-aws-vpc. The Registry parses this name to figure out which provider the module targets and what to call it, so a repository that does not follow the pattern will not publish correctly.
flowchart TB
subgraph pinned["Pinned version"]
p1["version = ~> 5.0"] --> p2["terraform init always resolves within 5.x"]
end
subgraph unpinned["Unpinned version"]
u1["no version constraint"] --> u2["terraform init -upgrade may resolve 6.0"]
u2 --> u3["breaking changes applied without changelog review"]
end