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 = ”~> 6.0”
Section titled “The pessimistic constraint operator: version = ”~> 6.0””Pinning a Registry or Git-tag module with a version argument fixes this. The common pattern is the pessimistic constraint operator, ~>:
# pinned - allows 6.x patch and minor upgrades, blocks a 7.0 major bumpmodule "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 6.0"}~> 6.0 means “allow any version greater than or equal to 6.0, up to but not including 7.0.” In other words, it lets Terraform pick up patch and minor releases within the 6.x line — bug fixes and backward-compatible additions — while refusing to silently jump to 7.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.
Proving ~> 6.0 for real: terraform-aws-modules/vpc/aws, current status, and where the resolved version actually lives
Section titled “Proving ~> 6.0 for real: terraform-aws-modules/vpc/aws, current status, and where the resolved version actually lives”terraform-aws-modules/vpc/aws — the same well-known, widely-used module named earlier in this course — is still actively maintained: as of this proof, ~> 6.0 resolves to a real, current release. Real terraform init output, no fabricated version number:
module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 6.0"
name = "acme-app-vpc" cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]}$ terraform initInitializing modules...Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 6.7.3 for vpc...- vpc in .terraform/modules/vpc
Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 6.0, >= 6.28.0"...- Installing hashicorp/aws v6.66.0...~> 6.0 resolved to 6.7.3 — a real, current patch/minor release within the 6.x line, proving the constraint’s promise: it moved past 6.0.0 to pick up fixes, but stayed inside the major version boundary. With real mock AWS credentials (skip_credentials_validation etc.), this module’s plan actually runs end to end:
$ terraform plan...Plan: 17 to add, 0 to change, 0 to destroy.Here is the 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/aws" { version = "6.66.0" constraints = "~> 6.0, >= 6.28.0" ...}No module entry anywhere in that file. 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":"vpc","Source":"registry.terraform.io/terraform-aws-modules/vpc/aws","Version":"6.7.3","Dir":".terraform/modules/vpc"}]}That means the version = "~> 6.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 7.0.
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_hcp" { 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 = ~> 6.0"] --> p2["terraform init always resolves within 6.x"]
end
subgraph unpinned["Unpinned version"]
u1["no version constraint"] --> u2["terraform init -upgrade may resolve 7.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 is not a bug in Terraform; it 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. Writing ~> 6.0 for a module is the entire durable record of what is acceptable — get it wrong, or leave it off, and the next init genuinely can resolve somewhere you did not review.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
version = "~> 6.0" on a Registry module | Allows real patch/minor fixes (proven above: resolved to 6.7.3) while blocking an unreviewed 7.0 jump | Still trusts the module author’s semantic versioning discipline — a “minor” release can still contain an unintentional breaking change |
| 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-aws-modules/vpc/awsproduced a.terraform.lock.hclwith only aprovider "registry.terraform.io/hashicorp/aws"block — no module entry anywhere. The resolved module version lives in.terraform/modules/modules.jsoninstead, which is not meant to be committed. - “
terraform-aws-modules/vpc/awsis an old, possibly-abandoned community module.” Checked directly above:~> 6.0resolved to a real, current6.7.3release, and a fullplanagainst it (with mock credentials) succeeded with17 to add— this module is actively maintained as of this proof. - “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 — it is the only thing standing between you and an unreviewed major-version jump.
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, and it is easy to miss in a quick review since the block still “looks complete.” - 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, not anywhere committed. - A
versionconstraint using=(exact pin) where~>was clearly intended, or vice versa — an exact pin blocks even bug-fix releases, while~>was proven above to let real patch/minor fixes through automatically. - 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-aws-modules/vpc/aws"version = "~> 6.0"}# ...six months later, deployed via CI...# "the module must have changed the lock file, let's check .terraform.lock.hcl"Show the answer
Proven above: there is nothing to check in .terraform.lock.hcl for this module — it only ever contains provider entries. The comment’s assumption is exactly the misconception this lesson corrects: a module’s resolved version is never recorded in a committed lock file the way a provider’s is. If this module’s behavior changed over six months, the real place to look is terraform init’s own log output from the run that changed it (which version it downloaded), or .terraform/modules/modules.json on that specific machine at that specific time — and going forward, the fix is making the version constraint itself precise enough (or switching to = for a hard pin) that a repeat of “which version are we even running” cannot happen, since there is no lock file to fall back on for modules the way there is for providers.