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 "vnet" { source = "Azure/avm-res-network-virtualnetwork/azurerm"}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, proven for real: version = "~> 0.2"
Section titled “The pessimistic constraint operator, proven for real: version = "~> 0.2"”Pinning a Registry or Git-tag module with a version argument fixes this. The common pattern is the pessimistic constraint operator, ~>. This section uses Azure Verified Modules (AVM) rather than the older Azure/vnet/azurerm community module some tutorials still reference — checked directly against GitHub, that repository carries a real “This repository was archived by the owner” banner and points adopters at AVM as its replacement, so this course teaches the module that is actually current:
module "vnet" { source = "Azure/avm-res-network-virtualnetwork/azurerm" version = "~> 0.2"
location = "eastus" parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/acme-rg" address_space = ["10.0.0.0/16"] name = "acme-vnet" enable_telemetry = false}Real terraform init output — genuinely resolved, no fabricated version number — against this course’s own pinned azurerm ~> 5.0:
$ terraform initInitializing modules...Downloading registry.terraform.io/Azure/avm-res-network-virtualnetwork/azurerm 0.22.2 for vnet...
Initializing provider plugins...- Finding hashicorp/random versions matching "~> 3.5, ~> 3.6"...- Finding hashicorp/azurerm versions matching "~> 5.0"...- Finding azure/azapi versions matching "~> 2.4, ~> 2.12"...- Finding azure/modtm versions matching "~> 0.3"...- Installing hashicorp/random v3.9.1...- Installing hashicorp/azurerm v5.7.0...- Installing azure/azapi v2.13.0...- Installing azure/modtm v0.4.0...$ terraform validateSuccess! The configuration is valid.~> 0.2 resolved to 0.22.2 — a real, current release. Notice what init did on your behalf: this AVM module needs azure/azapi (its resources are implemented via the generic azapi_resource type, not hand-written azurerm_virtual_network calls) and azure/modtm (anonymous usage telemetry, disabled here with enable_telemetry = false) as additional providers — neither of which this course’s own required_providers block declares, and both of which init pulled in automatically because the module’s own manifest requires them. ~> 5.0 means “allow any version greater than or equal to 5.0, up to but not including 6.0” for the azurerm provider specifically; the module version constraint ~> 0.2 follows the identical pattern one level up, for the module’s own release line.
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/azurerm" version = "~> 2.1"}If you do want to publish a module publicly, the Terraform Registry expects a specific repository naming convention: terraform-<PROVIDER>-<NAME>. AVM itself follows this exactly — the module proven above lives in a GitHub repository literally named terraform-azurerm-avm-res-network-virtualnetwork. 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.
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 same init above, .terraform.lock.hcl contents (trimmed):
provider "registry.terraform.io/hashicorp/azurerm" { version = "5.7.0" constraints = "~> 5.0" ...}No module entry anywhere in that file — only providers (azurerm, random, azapi, modtm, all pulled in either directly or by the module itself). The resolved module version 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":"vnet","Source":"registry.terraform.io/Azure/avm-res-network-virtualnetwork/azurerm","Version":"0.22.2","Dir":".terraform/modules/vnet"}, ...]}That means the version = "~> 0.2" 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 1.0 (or beyond).
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 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, proven above, the constraint’s own shape matters more than it looks: ~> 0.2 and ~> 0.2.0 are not the same promise at all for a pre-1.0 module.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
version = "~> 0.2" (two segments) on a Registry module | Follows real releases automatically within the same major line — proven above, resolved to a real, current 0.22.2 | For a pre-1.0 module, “the same major line” means all of 0.x — proven above, this is a much wider range than the three-segment form, and can still pull in a genuinely different interface release by release |
version = "~> 0.2.0" (three segments) | Locks to patch-only updates within 0.2.x — proven above, resolved to the much older 0.2.4 | Misses real fixes and features published in later 0.x minor releases; for an actively-developed pre-1.0 module this can mean staying on a stale release indefinitely |
| 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”- “
Azure/vnet/azurermis still the standard starting point for Azure networking modules.” Checked directly against GitHub: the repository carries a real “This repository was archived by the owner” banner and points adopters at Azure Verified Modules — proven above,Azure/avm-res-network-virtualnetwork/azurermis the current, actively maintained answer, and it genuinely resolves and validates against this course’s pinnedazurerm ~> 5.0. - “A module’s resolved version gets recorded in
.terraform.lock.hcl, the same as a provider’s.” Proven above: a realinitagainst the AVM vnet module produced 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. - “
~> 0.2and~> 0.2.0mean roughly the same thing, just with different precision.” Proven above by running both against the real Registry:~> 0.2resolved to0.22.2(any0.xrelease>= 0.2), while~> 0.2.0resolved to0.2.4(patch releases within0.2.xonly) — for a pre-1.0 module, dropping the third segment changes which component the pessimistic operator is allowed to increment, not just how tightly it pins. - “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
moduleblock still sourcingAzure/vnet/azurermorAzure/naming/azurerm— checked above, both are archived; the current answer is an Azure Verified Module. - A two-segment
~>constraint (like~> 0.2) used on a pre-1.0 module with the apparent intent of pinning tightly to a minor line — proven above, this actually allows a much wider range than the author likely intended;~> 0.2.0is the tighter form. - 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>— proven above, even AVM itself follows this convention exactly.
🐛 Spot the bug
module "vnet" {source = "Azure/vnet/azurerm"version = "~> 5.0"
resource_group_name = azurerm_resource_group.main.namevnet_name = "acme-app-vnet"address_space = ["10.0.0.0/16"]}Show the answer
Proven above by fetching the real GitHub repository: Azure/vnet/azurerm carries a genuine “This repository was archived by the owner” banner and its README points adopters at Azure Verified Modules as the replacement — this module is not receiving updates, security fixes, or new azurerm provider compatibility work, no matter how the version constraint is written. A generated module call correctly following every convention in this lesson (a real ~> constraint, a real Registry address) can still be pointing at a dead end if the address itself is stale. The fix is the module proven throughout this lesson instead: source = "Azure/avm-res-network-virtualnetwork/azurerm", version = "~> 0.2", with the AVM interface (parent_id, address_space, name, enable_telemetry) rather than the archived module’s (resource_group_name, vnet_name) — proven above to init and validate cleanly against this course’s own pinned azurerm ~> 5.0.