DEV Community

Francis Eytan Dortort
Francis Eytan Dortort

Posted on • Originally published at dortort.com

A Practical Guide to Terraform Dependency Management

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 disciplined init -upgrade workflows).

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.

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.hcl and 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

Mermaid Diagram

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.0 means >= 5.0.0, < 6.0.0
  • ~> 5.0.3 means >= 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"
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Why ~> 5.0 is usually the sweet spot:

  • It creates an explicit upper bound (no surprise major break).
  • Within the bound, .terraform.lock.hcl makes runs repeatable unless you explicitly run terraform init -upgrade.

When to prefer an explicit range:

version = ">= 5.10.0, < 5.30.0"
Enter fullscreen mode Exit fullscreen mode
  • 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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

What you gain:

  • Easier to consume patches/minors within the major line.

What you pay:

  • The selected module version can change whenever terraform init resolves 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 init in a clean workspace.
  • Terraform resolves to a newer 5.x module release than you were using before.
  • terraform plan now 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 + scheduled init -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 -upgrade on a schedule in a dedicated branch
    • validate plans across your supported provider/version matrix for reusable modules

Further Reading

Terraform Documentation

Tutorials & Guides

Compatibility & Upgrades

Comparative Guidance

Top comments (0)