Module Versioning and Registries
The idea in one sentence
Section titled “The idea in one sentence”A module’s version constraint pins which release of a Registry or Git-tagged module Terraform is allowed to resolve, for exactly the same reason a provider version constraint matters: without it, a routine terraform init -upgrade could silently pull in a newer module release with breaking changes that nobody reviewed.
Why pinning a module version matters
Section titled “Why pinning a module version matters”This is the same underlying problem the provider dependency lock file solves, just one level up the stack. When a module block sources from the public Registry or a Git tag without a version constraint, Terraform resolves the newest available release every time init runs cold — a fresh clone in CI, a new laptop, a rebuilt cache. If the module’s author ships a breaking change in that newer release — a renamed variable, a different default, a resource that now gets replaced instead of updated — it gets pulled in and applied without anyone on your team ever reading that module’s changelog first.
module "vpc" { source = "terraform-google-modules/network/google" # no version constraint — always resolves to the latest release
project_id = var.project_id network_name = "app-network"}Pinning a version turns that into a deliberate, reviewable decision instead of something that happens to you by accident:
module "vpc" { source = "terraform-google-modules/network/google" version = "~> 18.0"
project_id = var.project_id network_name = "app-network"}The pessimistic constraint operator: ~>
Section titled “The pessimistic constraint operator: ~>”The ~> operator is called the pessimistic constraint operator, and it means allow upgrades, but only ones unlikely to break anything. version = "~> 18.0" allows Terraform to resolve any 18.x release — 18.1.0, 18.4.2, 18.12.0 — since those are supposed to be backward compatible under semantic versioning, but it blocks 19.0.0 outright, because a major version bump is exactly where a module’s author is allowed to introduce breaking changes.
The same operator works one segment more precisely too: version = "~> 18.2.0" allows patch releases like 18.2.1 and 18.2.7, but blocks 18.3.0 — useful when you want to hold even minor-version bumps until you have reviewed them. ~> 18.0, allowing the whole 18.x line, is the common default for most teams; the tighter ~> 18.2.0 form is for modules where you have been burned by minor-version behavior changes before.
Upgrading past a blocked boundary is still one command away when you are ready — bump the version constraint in your .tf file, then run terraform init -upgrade — the constraint just makes sure that upgrade is something a person decided to do, not something that happened silently on the next init.
Proving ~> 18.0 for real: terraform-google-modules/network/google, and the subnets argument it actually requires
Section titled “Proving ~> 18.0 for real: terraform-google-modules/network/google, and the subnets argument it actually requires”terraform-google-modules/network/google is a real, current module — ~> 18.0 genuinely resolves. But v18 requires an argument the examples above never showed: subnets. Real terraform validate output, proving the omission is a genuine error and not a style choice:
module "vpc" { source = "terraform-google-modules/network/google" version = "~> 18.0"
project_id = "acme-app-prod" network_name = "app-network"}$ terraform validateError: Missing required argument
on main.tf line 10, in module "vpc": 10: module "vpc" {
The argument "subnets" is required, but no definition was found.subnets is not optional in v18, even for a minimal VPC. The real, corrected, validated call:
module "vpc" { source = "terraform-google-modules/network/google" version = "~> 18.0"
project_id = "acme-app-prod" network_name = "app-network"
subnets = [ { subnet_name = "app-subnet-us-central1" subnet_ip = "10.0.1.0/24" subnet_region = "us-central1" } ]}$ terraform init...$ terraform validateSuccess! The configuration is valid.~> 18.0 resolved to a real, current 18.3.0 — proof this module is actively maintained, not the outdated, no-longer-representative example a course could silently carry forward. A required-argument change like this is exactly the kind of thing ~> protects you from crossing unreviewed at a major-version boundary (here, 17.x to 18.x) — but it does not protect you from a stale copy-pasted example that was never updated to match the version actually pinned.
Where the resolved module version actually lives — not .terraform.lock.hcl
Section titled “Where the resolved module version actually lives — not .terraform.lock.hcl”Here is a detail worth correcting explicitly, since it is easy to assume wrongly by analogy with providers: a resolved module version is never recorded in .terraform.lock.hcl. That file is provider-only. Proof — the resolved module version from the init above instead lands in .terraform/modules/modules.json, which terraform init regenerates on every run and which is not meant to be committed to version control (it lives inside the gitignored .terraform/ directory, alongside the downloaded module source itself):
$ cat .terraform/modules/modules.json{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"vpc","Source":"registry.terraform.io/terraform-google-modules/network/google","Version":"18.3.0","Dir":".terraform/modules/vpc"}, ...]}No module entry exists anywhere in .terraform.lock.hcl — only provider blocks. That means the version = "~> 18.0" constraint in your .tf file is the only durable, committed record of which module versions are acceptable — unlike a provider, there is no separate lock file pinning the exact resolved module version for next time. Every init re-resolves the constraint against whatever the Registry currently has published within it, which is exactly why the constraint itself, not a lock file, is what protects you from an unreviewed jump to 19.0.
Private sources and publishing your own module
Section titled “Private sources and publishing your own module”Not every module belongs on the public Registry. A module that encodes one company’s specific security policies, naming conventions, or internal service wiring has no reason to be public, and probably should not be. Terraform supports the same source mechanics for these cases — a private Git repository (often over SSH, so init can authenticate with existing credentials) or a private module registry, such as HCP Terraform’s private registry or an internally hosted one:
module "internal_service" { source = "git::ssh://[email protected]/acme-corp/terraform-modules.git//internal-service?ref=v1.4.0"
project_id = var.project_id}If you do want to publish a module of your own to the public Terraform Registry, the Registry expects a specific repository naming pattern: terraform-<PROVIDER>-<NAME>, for example terraform-google-vpc for a module targeting the google provider. The Registry parses that name directly into the module’s address (namespace/name/provider), so a repository named anything else will not be picked up.
flowchart LR
subgraph pinned["Pinned: version = ~> 18.0"]
p1["terraform init today"] --> p2["Resolves 18.4.2 (latest 18.x)"]
p2 --> p3["terraform init -upgrade later"]
p3 --> p4["Still resolves within 18.x, blocks 19.0.0"]
end
subgraph unpinned["Unpinned: no version constraint"]
u1["terraform init today"] --> u2["Resolves 18.4.2 (latest overall)"]
u2 --> u3["terraform init tomorrow"]
u3 --> u4["Silently resolves 19.0.0 with breaking changes"]
end Mental Model
Section titled “Mental Model”A provider’s exact resolved version is pinned twice: once loosely by your version constraint, once exactly by .terraform.lock.hcl. A module’s resolved version is pinned only once — by your version constraint alone, proven above by the real absence of any module entry in .terraform.lock.hcl. That asymmetry means the constraint string in your .tf file carries more weight for modules than the equivalent string does for providers, because there is no second, committed safety net catching a surprise resolution the way the lock file does for providers. And ~> only protects you from unreviewed major-version jumps — proven above, it does nothing to protect a course example (or your own module call) that was simply never updated to match a required argument the pinned version actually needs.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
version = "~> 18.0" on a Registry module | Allows real patch/minor fixes (proven above: resolved to 18.3.0) while blocking an unreviewed 19.0 jump | Still requires you to actually re-validate against the resolved version — proven above, ~> 18.0 alone did not catch that subnets had become required |
| No module lock file (unlike providers) | One less generated file to keep in sync; .terraform/modules/ regenerates cleanly every init | The version constraint string is the only committed record of acceptable module versions — proven above, there is no .terraform.lock.hcl entry backing it up |
| Private Git repo or private registry for internal modules | Company-specific infrastructure details never become public | Requires your own versioning discipline (real Git tags, a real private registry) instead of relying on the public Registry’s own conventions |
Common Misconceptions
Section titled “Common Misconceptions”- “A module’s resolved version gets recorded in
.terraform.lock.hcl, the same as a provider’s.” Proven above: a realinitagainstterraform-google-modules/network/googleproduced a.terraform.lock.hclwith onlyproviderblocks — no module entry anywhere. The resolved module version lives in.terraform/modules/modules.jsoninstead, which is not meant to be committed. - “If a module’s
versionconstraint is correct, the example code that worked before still works.” Proven above by a real error:subnetsbecame a required argument at some point in the18.xline, and the exact sameproject_id/network_name-only call that might have validated against an older release fails against the current18.3.0with “Missing required argument.” - “Since modules don’t have a lock file, the version constraint doesn’t really matter much.” Proven above by the asymmetry itself: because there is no second, committed safety net for modules the way there is for providers, the constraint string is more load-bearing for modules, not less.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated module sources, check for:
- A Registry or Git-tag
moduleblock with noversion(or?ref=) argument at all — proven above, this is exactly the unpinned-version risk this lesson opens with. - A
terraform-google-modules/network/googlecall with nosubnetsargument — proven above, this is a real, current validate error against~> 18.0, not a hypothetical. - A comment or commit message claiming a module’s exact resolved version is “locked” or “pinned in the lock file” — proven above, that claim is only true for providers; a module’s actual resolved version lives in the gitignored
.terraform/modules/modules.json. - A private, company-specific module published with a generic name that does not follow
terraform-<PROVIDER>-<NAME>— this breaks Registry auto-detection for public modules, and is worth flagging even for a private registry, where the same naming discipline keeps modules discoverable.
🐛 Spot the bug
module "vpc" {source = "terraform-google-modules/network/google"version = "~> 18.0"
project_id = "acme-app-prod"network_name = "app-network"}Show the answer
Proven above with a real terraform validate error: terraform-google-modules/network/google genuinely requires a subnets argument as of the pinned ~> 18.0 line — “The argument `subnets` is required, but no definition was found.” This is exactly the kind of thing a correct version constraint does not protect against: ~> 18.0 legitimately resolved to a real, current 18.3.0, the constraint did its job, but the module call itself was never updated to match what that version actually requires. The fix is adding a real subnets list — proven above to validate cleanly once at least one subnet block (subnet_name, subnet_ip, subnet_region) is supplied.