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
Last updated: August 2026

10.4 Managing Module Versions

Quick Answer: version works 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 -upgrade and terraform get -update refresh already-installed modules. .terraform.lock.hcl does 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 kindHow you pin itWhat happens if you add version
Public / HCP / TFE registryversion = "~> 5.1"Terraform selects a published module version
Relative local ./modules/vpcYou do not. The directory is the versionversion does not apply — local modules share the caller's source tree
Absolute local pathYou do not. It is a copied package from diskSame as other non-registry sources
Git / GitHub / Bitbucket?ref=v1.2.0 or a commit SHAversion is not a Git selector
HTTP / S3 / GCS archivePut a version in the object key (vpc-1.2.0.zip) or URLNo 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:

OperatorMeaningExample
= or no operatorExactly 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:

  1. When your infrastructure depends on third-party modules, require specific versions so updates happen only when you choose.
  2. When your organization publishes modules with real semver and a release process, a range is acceptable.
  3. 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 skipping version on the module block 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.2 if 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.

CommandModules already installedAlso upgrades providers?
terraform initLeave them. Install only newly added modulesNo. Reuses .terraform.lock.hcl selections
terraform init -upgradeUpdate every module to the newest source that still matches version / refYes. Also ignores lock-file provider selections within constraints
terraform getSame as init's module step: install missing modulesNo. Modules only
terraform get -updateRecheck already-downloaded modules and download updatesNo. 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

TopicRegistry moduleGit module
Pin syntaxversion argument?ref= query parameter
What you pinA published semver the registry indexesA Git tag, branch, or commit
Who resolves itTerraform Registry / HCP / TFE APIgit clone + git checkout
Newest matchingNewest published version that satisfies the constraintWhatever commit ref points at now
Recorded in .terraform.lock.hcl?NoNo

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

  • version on ./modules/vpc does nothing useful. Local modules are not a registry.
  • version on git::https://... does not select a tag. Use ?ref=.
  • Omitting version on a registry source means "latest."
  • terraform init without -upgrade will not move an already-installed module.
  • .terraform.lock.hcl is not a module lock file.
  • required_version constrains the Terraform CLI, not child modules.
Test Your Knowledge

A module is sourced from ./modules/vpc. You add version = "~> 2.0" to the module block. What happens on Terraform 1.12?

A
B
C
D
Test Your Knowledge

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?

A
B
C
D
Test Your Knowledge

Why pin module versions in production, and how do registry pins differ from Git pins on Terraform 1.12?

A
B
C
D