Study guide
10.4 Managing Module Versions
HashiCorp Certified: Terraform Associate 004
Track your progress as you read
Finished sections get a check mark.
1 Introduction & Exam Overview
1.1 Current Exam Facts 1.2 Format, Delivery, Retakes, and Recertification 1.3 004 Changes, Terraform 1.12, and Study Plan
2 Infrastructure as Code with Terraform
2.1 What Infrastructure as Code Is 2.2 Advantages of IaC Patterns 2.3 Multi-Cloud, Hybrid, and Service-Agnostic Workflows
3 Terraform Fundamentals: Providers & State
3.1 Install and Version Terraform Providers 3.2 How Terraform Uses Providers 3.3 Configuration with Multiple Providers 3.4 How Terraform Uses and Manages State
4 Core Terraform Workflow: Init & Validate
4.1 The Terraform Write–Plan–Apply Workflow 4.2 Initialize a Working Directory 4.3 Validate a Configuration
5 Plan, Apply, Destroy, and Formatting
5.1 Generate and Review an Execution Plan 5.2 Apply Changes to Infrastructure 5.3 Destroy Terraform-Managed Infrastructure 5.4 Formatting and Style Adjustments
6 Resources, Data Sources, and References
6.1 Resource Blocks vs Data Blocks 6.2 Resource Attributes and Cross-Resource References 6.3 Resource Addressing and Interpolation Patterns
7 Variables, Outputs, and Complex Types
7.1 Input Variables, tfvars, and Precedence 7.2 Output Values 7.3 Complex Types: list, map, object, tuple, set
8 Dynamic Configuration and Dependencies
8.1 Expressions, count, for_each, and dynamic Blocks 8.2 Built-in Functions 8.3 Dependencies, depends_on, and Lifecycle Rules
9 Conditions, Secrets, and Sensitive Data
9.1 Custom Conditions: validation, precondition, postcondition, check 9.2 Sensitive Data Handling 9.3 Vault, Ephemeral Values, and Write-Only Arguments
10 Terraform Modules
10.1 How Terraform Sources Modules 10.2 Variable Scope within Modules 10.3 Using Modules in Configuration 10.4 Managing Module Versions
11 Terraform State Management
11.1 The Local Backend 11.2 State Locking 11.3 Remote State and the backend Block 11.4 Resource Drift and State Operations
12 Maintain Infrastructure with Terraform
12.1 Import Existing Infrastructure 12.2 Inspect State with the CLI 12.3 Verbose Logging and Diagnostics
13 HCP Terraform
13.1 Create Infrastructure with HCP Terraform 13.2 Collaboration and Governance Features 13.3 Workspaces and Projects 13.4 HCP Terraform Integrations
10.4 Managing Module Versions
Key Takeaways Explain This Topic AI
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 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 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.
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
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.