10.4 Managing Module Versions
Key Takeaways
- The version argument works only for registry modules (public, HCP, or Terraform Enterprise), not for plain local paths or raw Git URLs
- Registry version constraints use the same operators as providers: =, !=, >=, ~>, and comma-separated ranges
- Git modules are pinned with the ref query parameter (tag, branch, or SHA); putting version on a git:: source does not select a Git tag
- Pin modules in production so a later install cannot silently change infrastructure; HashiCorp recommends explicit constraints on third-party modules
- terraform get -update and terraform init -upgrade refresh installed modules; re-running init without those flags leaves already-installed modules alone, and .terraform.lock.hcl does not record module versions
10.4 Managing Module Versions
Quick Answer:
versionworks for registry modules only. Constraints look like provider constraints (~> 5.1,>= 1.2.0, < 2.0.0). Git modules pin with?ref=. Local./paths have no version. Pin in production.terraform init -upgradeandterraform get -updaterefresh already-installed modules..terraform.lock.hcldoes not record module versions.
Objective 5d on Terraform Associate (004) is: manage module versions. Product version is Terraform 1.12. Official pages: the version argument, Version constraints, terraform init, terraform get, and the dependency lock file.
Where version is legal
module "consul" {
source = "hashicorp/consul/aws"
version = "~> 0.11.0"
}
module "private_vpc" {
source = "app.terraform.io/acme/vpc/aws"
version = ">= 2.1.0, < 3.0.0"
}
HashiCorp is explicit: you can use version only when source points at a module listed in a registry — the public Terraform Registry, HCP Terraform's private registry, or a Terraform Enterprise / custom registry that implements the module registry protocol. The registry API publishes semver tags; Terraform asks that API for the newest version that satisfies the constraint.
| Source kind | How you pin it | What happens if you add version |
|---|---|---|
| Public / HCP / TFE registry | version = "~> 5.1" | Terraform selects a published module version |
Relative local ./modules/vpc | You do not. The directory is the version | version does not apply — local modules share the caller's source tree |
| Absolute local path | You do not. It is a copied package from disk | Same as other non-registry sources |
| Git / GitHub / Bitbucket | ?ref=v1.2.0 or a commit SHA | version is not a Git selector |
| HTTP / S3 / GCS archive | Put a version in the object key (vpc-1.2.0.zip) or URL | No registry version argument |
Official language for local paths: they are loaded from the same source repository as the caller and always share that version. There is nothing for a constraint to select.
If you omit version on a registry module, Terraform uses the newest version the registry offers. That is convenient in a scratch directory and reckless in production.
Constraint syntax — the same language as providers
A version constraint is a string of one or more conditions separated by commas. Terraform 1.12 uses the same operators you already memorized for required_providers:
| Operator | Meaning | Example |
|---|---|---|
= or no operator | Exactly one version. Cannot combine with other conditions | = 1.2.0 or 1.2.0 |
!= | Exclude one version | != 1.2.3 |
>, >=, <, <= | Comparison | >= 1.2.0, < 2.0.0 |
~> | Only the right-most component may increment | ~> 1.0.4 allows 1.0.5 and 1.0.10, not 1.1.0; ~> 1.1 allows 1.2, not 2.0 |
Terraform uses the newest already installed version that meets the constraint. If none is installed, it downloads the newest version that meets the constraint. Pre-release versions such as 1.2.0-beta match only an exact = constraint; they do not satisfy >= or ~>.
HashiCorp's published module-version guidance:
- When your infrastructure depends on third-party modules, require specific versions so updates happen only when you choose.
- When your organization publishes modules with real semver and a release process, a range is acceptable.
- Reusable child modules should constrain Terraform and providers with a minimum (
>=) and leave the upper bound to the root. That advice is about provider constraints inside a module, not about skippingversionon themoduleblock that calls a third-party module.
After you change version, run terraform init. The working directory still has the old snapshot until init replaces it.
Git ref is the Git pin
module "vpc" {
source = "git::https://example.com/vpc.git?ref=v1.2.0"
}
module "vpc_sha" {
source = "git::https://example.com/vpc.git?ref=51d462976d84fdea54b47d80dcabbf680badcdb8"
}
A Git source is not a registry. Terraform clones the repository and checks out ref. A floating branch (?ref=main) is a moving target: the next init -upgrade can pick a new commit. A tag is better. A full SHA is the strongest pin and survives a force-pushed tag.
Do not write:
module "vpc" {
source = "git::https://example.com/vpc.git"
version = "1.2.0" # does not select a Git tag
}
That is the classic 5d distractor. version talks to a registry API. ref talks to Git. Combining a registry source with ?ref= is equally wrong.
GitHub / Bitbucket shorthand addresses follow the same ref rule: github.com/acme/vpc?ref=v1.2.0.
Why pin versions in production
An unpinned registry module means "newest published edition, whatever that is this morning." An unpinned Git module means "current HEAD of the default branch." Either one can change a security-group rule, a breaking input, or a resource name between two applies on two laptops. That is the "it worked yesterday" incident this objective is testing you to prevent.
Pinning is how you make the next terraform apply reproduce the last one:
- Registry:
version = "5.1.2"or a tight~> 5.1.2if you accept patch releases. - Git:
?ref=a tag you control, or a SHA for a hotfix. - Local: review the directory in the same pull request as the root. The pin is the commit that contains both.
HCP Terraform and CI runners do not reuse your laptop's .terraform/modules. They install from the addresses you committed. If those addresses are floating, every workspace can install a different snapshot. If they are pinned, every workspace installs the same module code even though the lock file will not help you (see below).
terraform get -update and terraform init -upgrade
terraform init installs modules that are not yet in .terraform/modules. It does not refresh modules that are already installed. That is official init behavior and a favorite 004 distinction.
| Command | Modules already installed | Also upgrades providers? |
|---|---|---|
terraform init | Leave them. Install only newly added modules | No. Reuses .terraform.lock.hcl selections |
terraform init -upgrade | Update every module to the newest source that still matches version / ref | Yes. Also ignores lock-file provider selections within constraints |
terraform get | Same as init's module step: install missing modules | No. Modules only |
terraform get -update | Recheck already-downloaded modules and download updates | No. Modules only |
Use terraform get -update when you want to refresh modules without touching provider plugins. Use terraform init -upgrade when you intend to move both. Neither command ignores a version constraint or a ref. To jump from module 5.x to 6.x you edit the constraint (or the tag), then upgrade.
Changing source always requires a new init, with or without -upgrade, because the install key changed.
Registry versus Git versioning, and the lock-file gap
| Topic | Registry module | Git module |
|---|---|---|
| Pin syntax | version argument | ?ref= query parameter |
| What you pin | A published semver the registry indexes | A Git tag, branch, or commit |
| Who resolves it | Terraform Registry / HCP / TFE API | git clone + git checkout |
| Newest matching | Newest published version that satisfies the constraint | Whatever commit ref points at now |
Recorded in .terraform.lock.hcl? | No | No |
The last row is the 004 trap that overlaps objective 2a. HashiCorp documents that the dependency lock file tracks provider dependencies only. Terraform does not remember version selections for remote modules. The only pin that survives a wiped .terraform/ directory is the version or ref you committed in the module block. Treat an exact version = "5.1.2" or a SHA ref as the module equivalent of the lock file.
004 traps for module versions
versionon./modules/vpcdoes nothing useful. Local modules are not a registry.versionongit::https://...does not select a tag. Use?ref=.- Omitting
versionon a registry source means "latest." terraform initwithout-upgradewill not move an already-installed module..terraform.lock.hclis not a module lock file.required_versionconstrains the Terraform CLI, not child modules.
A module is sourced from ./modules/vpc. You add version = "~> 2.0" to the module block. What happens on Terraform 1.12?
Registry modules are already installed in .terraform/modules. You want the newest versions still allowed by each module's version constraint. Which commands do that on Terraform 1.12?
Why pin module versions in production, and how do registry pins differ from Git pins on Terraform 1.12?