A Practical Guide to Terraform Dependency Management
Source: Dev.to
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 disciplinedterraform init -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.
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). The same operator can yield very different stability depending on whether Terraform writes down the chosen result.
Persistence differs:
| Artifact | Recorded in lock file? | Behaviour |
|---|---|---|
| Providers | Yes (.terraform.lock.hcl) | Selections are locked and reused by default |
| Modules | No | 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
graph TD
A[User defines constraints] --> B[Terraform resolves versions]
B --> C{Provider?}
C -->|Yes| D[Write to .terraform.lock.hcl]
C -->|No| E[No lock file entry]
D --> F[Future runs reuse locked version]
E --> G[Future runs may pick newer module version]
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 bug‑fix/security updates unless manually changed |
>= (alone) | “Anything newer is fine” | Future breakage + drift; depends on lock behavior |
~> | Convenient bounded range (e.g., ~> 5.0 means >= 5.0.0, < 6.0.0) | Easy to under/over‑constrain if you pick the wrong precision |
Example Interpretations (Terraform Semantics)
Provider version constraint
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
~> 5.0translates to>= 5.0.0, < 6.0.0.- The lock file records the exact provider version that was selected, making subsequent runs repeatable unless
terraform init -upgradeis invoked.
Module version constraint
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
- The same
~> 5.0range applies, but no lock file entry is created for the module. - Future runs may pick a newer
5.xrelease unless the version is explicitly upgraded with-upgradeor a tighter constraint is added.