Skip to content

Module Versioning and Registries

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 -upgrade
module "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 init
Initializing 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 validate
Success! 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 repository
module "internal_networking" {
source = "git::ssh://[email protected]/acme-corp/terraform-modules.git//networking?ref=v2.1.0"
}
# 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
A pinned module version versus an unpinned one that can silently resolve to a newer release

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.

ChoiceBenefitCost
version = "~> 0.2" (two segments) on a Registry moduleFollows real releases automatically within the same major line — proven above, resolved to a real, current 0.22.2For 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.4Misses 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 initThe 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 modulesCompany-specific infrastructure details never become publicRequires your own versioning discipline (real Git tags, a real private registry) instead of relying on the public Registry’s own conventions
  • “Azure/vnet/azurerm is 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/azurerm is the current, actively maintained answer, and it genuinely resolves and validates against this course’s pinned azurerm ~> 5.0.
  • “A module’s resolved version gets recorded in .terraform.lock.hcl, the same as a provider’s.” Proven above: a real init against the AVM vnet module produced a .terraform.lock.hcl with only provider blocks — no module entry anywhere. The resolved module version lives in .terraform/modules/modules.json instead, which is not meant to be committed.
  • “~> 0.2 and ~> 0.2.0 mean roughly the same thing, just with different precision.” Proven above by running both against the real Registry: ~> 0.2 resolved to 0.22.2 (any 0.x release >= 0.2), while ~> 0.2.0 resolved to 0.2.4 (patch releases within 0.2.x only) — 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.

When reviewing generated module sources, check for:

  • A Registry or Git-tag module block with no version (or ?ref=) argument at all — proven above, this is exactly the unpinned-version risk this lesson opens with.
  • A module block still sourcing Azure/vnet/azurerm or Azure/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.0 is 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.name
vnet_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.

What risk does leaving a Registry or Git-tag module source without a version constraint introduce
Is Azure/vnet/azurerm still the current, actively maintained starting point for Azure networking modules?
Where is the resolved module version 0.22.2 actually recorded, proven above?
$ terraform init
Downloading registry.terraform.io/Azure/avm-res-network-virtualnetwork/azurerm 0.22.2 for vnet...
...
$ cat .terraform.lock.hcl
provider "registry.terraform.io/hashicorp/azurerm" {
  version = "5.7.0"
  ...
}
What does this real, side-by-side result prove about ~> 0.2 versus ~> 0.2.0 for a pre-1.0 module?
$ terraform init  # version = "~> 0.2"
Downloading ... 0.22.2 for vnet...
$ terraform init  # version = "~> 0.2.0"
Downloading ... 0.2.4 for vnet...
Why might an organization use a private Git repository or private module registry instead of the public Terraform Registry for a module
What repository naming convention does the public Terraform Registry expect for publishing your own module