TL;DR
- Treat Terraform dependency management as two different systems: providers are selected and pinned via
.terraform.lock.hcl(repeatable by default), while modules are not pinned by a lock file and can drift over time unless you pin an exact version or a git ref.- Use bounded ranges for the Terraform CLI (
required_version) and pessimistic constraints (~>) for providers in root modules.- In reusable sub-modules, prefer broad minimums (plus optional upper bounds only when necessary), letting the root module do final resolution.
- For modules, choose explicitly between exact pins for maximum reproducibility, or
~>ranges for easier upgrades (with disciplinedinit -upgradeworkflows).
Specify a version constraint, run terraform init, done—except that providers and modules follow different resolution and persistence rules. Providers are locked; modules are not. That asymmetry is why teams get surprised by "nothing changed" configurations producing different results across machines or CI runs. Understanding these mechanics is especially important when structuring configurations across multiple environments and microservice architectures, where shared module versions must work across many consumers.
In this article, a root module means the top-level Terraform configuration you run (the directory you init/plan/apply). A reusable module means a library-style module consumed by other configurations. We'll build from the mechanics to a practical, testable policy for each.
The Real Problem: "Constraints" Do Not Mean "Pins"
A version constraint is a filter over acceptable versions (e.g., >= 5.0, < 6.0). Terraform then chooses an actual version using its resolver rules. Terraform's constraint language and the semantics of operators (including ~>) are documented and consistent across providers and modules.
But the persistence differs:
-
Provider selections are recorded in
.terraform.lock.hcland reused by default. - Module selections are not recorded in that lock file; module ranges can float as new versions are published.
Key insight: The same operator can yield very different stability depending on whether Terraform writes down the chosen result.
A Mental Model You Can Reason About
This behavior is documented: the lock file covers providers, not modules.
Operators: What They Really Buy You
Terraform supports standard comparison operators plus the pessimistic constraint ~> ("allow changes only to the rightmost specified component", i.e., a convenient bounded range).
How to Think About Each Operator
| Operator | Meaning (operational) | Primary risk |
|---|---|---|
= |
Hard pin | Blocks bugfix/security updates unless manually changed |
>= (alone) |
"Anything newer is fine" | Future breakage + drift; depends on lock behavior |
< / bounded range |
Explicit ceiling | Requires you to choose upgrade windows deliberately |
~> |
Convenient bounded range | Easy to under/over-constrain if you pick the wrong precision |
Example Interpretations (Terraform Semantics)
-
~> 5.0means>= 5.0.0, < 6.0.0 -
~> 5.0.3means>= 5.0.3, < 5.1.0
Root Module Policy: Reproducibility First, Upgrades by Intent
Root modules are where you want:
- Predictable CI behavior
- Stable planning across machines
- Controlled upgrades
1) Terraform CLI (required_version): Bounded Major
Terraform v1.x offers explicit compatibility promises, but minor releases can still include upgrade notes and non-breaking behavior changes.
Recommended:
terraform {
required_version = ">= 1.5.0, < 2.0.0"
}
Trade-off analysis:
- Pros: Avoids accidental major upgrade; permits minor/patch modernization.
- Cons: You must choose the floor; too-low floors prevent using newer language features.
2) Providers (required_providers): ~> at Major (or Explicit Bounded Range)
Terraform's own provider-versioning guidance warns that overly loose constraints can lead to unexpected changes, and recommends careful scoping in conjunction with the lock file.
Recommended:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
Why ~> 5.0 is usually the sweet spot:
- It creates an explicit upper bound (no surprise major break).
- Within the bound,
.terraform.lock.hclmakes runs repeatable unless you explicitly runterraform init -upgrade.
When to prefer an explicit range:
version = ">= 5.10.0, < 5.30.0"
- You're in a regulated environment.
- You've validated only a subset of minors.
- You want tighter control than "any 5.x".
Reusable Sub-Module Policy: Compatibility First, Narrow Only When Justified
A reusable module is a library: the consumer (root module) must be able to combine multiple modules without constraint conflicts. Terraform requires modules to declare provider requirements so a single provider version can be chosen across the module graph.
Providers in Sub-Modules: Set Minimums, Avoid Forcing Upgrades
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.0, < 6.0"
}
}
}
Trade-off analysis:
- Pros: Maximum compatibility; fewer "solver conflicts" for users.
- Cons: You must test against more provider versions (CI matrix helps).
This pattern—broad constraints in libraries, tight constraints in applications—is standard across ecosystems. OpenTofu's documentation makes the same distinction.
Modules: Where Most Teams Get Surprised
Terraform strongly recommends specifying module versions, and notes that omitting version loads the latest module.
But there's a deeper point: module selections aren't pinned by the dependency lock file. The lock file is for providers.
This is a design choice: Terraform's dependency lock file is scoped to provider packages and their checksums. Module selection is treated as an input to init (resolved when modules are installed), not as a locked artifact recorded for reuse across runs.
So you must choose between two legitimate strategies:
Strategy A: Pin Exact Module Versions (Maximum Reproducibility)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
}
What you gain:
- If your configuration hasn't changed, the module won't change just because time passed.
What you pay:
- You must bump versions intentionally (which is often good governance).
Strategy B: Use ~> Ranges (Upgradeable by Default, but Drift Is Possible)
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
What you gain:
- Easier to consume patches/minors within the major line.
What you pay:
- The selected module version can change whenever
terraform initresolves again, because there's no lockfile record.
What "Drift" Looks Like in Practice
This is the common surprise: you haven't changed .tf files, but a fresh checkout (or a cleaned .terraform/) pulls a newer module version inside your allowed range.
Example scenario:
- You have
version = "~> 5.0"for a registry module. - A teammate (or CI) runs
terraform initin a clean workspace. - Terraform resolves to a newer
5.xmodule release than you were using before. -
terraform plannow shows changes you didn't intend, even though your configuration didn't change.
If you want "same inputs → same plan" as the default across machines, pin exact module versions (or a git ref) and upgrade on purpose.
Practical Guidance: Choosing Constraints That Match Your Workflow
If You Want Reproducibility as the Default
-
CLI:
>= X, < 2.0.0 -
Providers:
~>at major + commit.terraform.lock.hcl -
Modules: exact versions (registry) or git
ref=pins
If You Want Faster Upgrades with Guardrails
- CLI: bounded major
-
Providers:
~>at major + scheduledinit -upgrade+ review lockfile diffs -
Modules:
~>ranges + explicit "module upgrade" PRs + CI validation
Terraform itself recommends including the dependency lock file in version control so dependency changes are reviewable.
Constraint Conflicts in Module Trees
A common failure mode in larger stacks:
- Sub-module A requires
aws < 5.0 - Sub-module B requires
aws >= 5.10 - Root module tries to set
aws ~> 5.0
Terraform adheres to a strict "diamond dependency" rule: the entire graph must share a single version of any given provider. If Module A demands aws < 5.0 and Module B demands aws >= 5.10, terraform init will fail. Broad constraints in libraries prevent these unresolvable conflicts.
Takeaways
- Treat providers and modules differently: one is lock-pinned, the other is not.
-
In root modules, use bounded ranges and commit
.terraform.lock.hcl. - In reusable modules, set broad minimums to avoid forcing consumers into upgrades.
-
Decide explicitly whether you optimize for reproducibility (exact module pins) or upgrade velocity (
~>module ranges with disciplined upgrade workflows). -
Add CI checks that:
- diff
.terraform.lock.hcl - run
terraform init -upgradeon a schedule in a dedicated branch - validate plans across your supported provider/version matrix for reusable modules
- diff
Further Reading
Terraform Documentation
- Terraform: Version Constraints (operators and ~>)
- Terraform: Dependency Lock File (.terraform.lock.hcl)
- Terraform: Use Modules in Configuration (module version argument)
Tutorials & Guides
- Terraform: Lock and Upgrade Provider Versions (tutorial)
- Terraform Tutorial: Use Registry Modules (recommends specifying module versions)
Compatibility & Upgrades
- Terraform: Terraform v1.x Compatibility Promises
- Terraform: Upgrade Guides (examples of minor-release upgrade notes)
Top comments (0)