Managing Terraform & Provider Versions
Why Version Pinning Exists
You build infrastructure with Terraform 1.5 today. Six months later a teammate clones the repo and runs terraform init. They have Terraform 1.12 installed. New versions introduce subtle behavior changes — different plan output formatting, new validation rules, deprecated arguments. Their plan looks different from yours. The CI pipeline produces different results than local runs. Confusion ensues, and nobody can explain why.
Version pinning solves this problem completely. You declare exactly what versions are acceptable. Everyone on the team, every CI/CD pipeline, every environment — same Terraform binary version, same provider versions, same behavior. Deterministic builds are not a luxury; they’re a requirement for any team that wants to trust their automation.
The underlying principle is the same one that drives package-lock.json in Node, Pipfile.lock in Python, or go.sum in Go. Infrastructure dependencies are no different from application dependencies — they need to be locked to specific versions for reproducibility.
Pinning Terraform Itself: required_version
Inside the terraform {} block, you specify which versions of the Terraform binary your config is compatible with:
terraform {
required_version = "~> 1.5"
}
If someone tries to run this config with Terraform 2.0 installed, Terraform exits immediately — before doing anything, before reading state, before touching any cloud API:
Error: Unsupported Terraform Core version
This configuration does not support Terraform version 2.0.0.
To proceed, either choose another supported Terraform version
or update this root module's version constraint.
This is a hard stop, not a warning. Terraform refuses to run. This protects you from accidentally applying infrastructure changes with an incompatible version that might interpret your config differently.
Useful constraint patterns:
required_version = ">= 1.5" # At least 1.5 (no upper limit — risky)
required_version = "~> 1.5" # 1.5.x through 1.x ≥ 1.5, < 2.0
required_version = "~> 1.5.0" # Very strict: >= 1.5.0, < 1.6.0
required_version = ">= 1.5, < 2.0" # Explicit range — any 1.x from 1.5 onward
~> 1.5 is the sweet spot for most teams. It allows patch and minor releases (bug fixes and new features) but blocks the major version jump to 2.0 that might introduce breaking changes. More protective than >= 1.5 but more flexible than = 1.5.0.
Why not just use >= 1.5? Because someday Terraform 2.0 will ship with breaking changes, and your config will silently break. Version constraints are insurance against the future. Set them once, revisit when you intentionally upgrade.
The Lock File: .terraform.lock.hcl Deep Dive
When you run terraform init, Terraform resolves provider versions and creates or updates .terraform.lock.hcl. This file pins the exact installed version of every provider.
# .terraform.lock.hcl
provider "registry.terraform.io/hashicorp/aws" {
version = "5.31.0"
constraints = "~> 5.0"
hashes = [
"h1:tXEB7GoBtBQeA+bfFVa5b0EcCVpRd5PFxX3UrHHDHmE=",
"zh:0843017ecc24385f2b45f2c5fce79dc25b258e50d516877b3affee3bef34f060",
]
}
Breaking it down:
| Field | What it means | Why it matters |
|---|---|---|
version | The exact version that was selected and installed | Guarantees everyone uses the same version |
constraints | Your constraint from required_providers | Documents what versions are acceptable |
h1: hashes | SHA-256 hashes of the provider binary | Verifies integrity — prevents supply-chain attacks |
zh: hashes | Hashes of the zip archives | Used by some download methods for verification |
The hashes are the security layer. When Terraform downloads a provider, it computes the hash of what it received and compares it against the lock file. If they don’t match, Terraform refuses to proceed. This prevents a compromised registry or man-in-the-middle attack from injecting malicious provider code into your infrastructure pipeline.
Normal Init vs. Upgrade Init — A Critical Distinction
# First run — downloads providers, creates lock file
terraform init
# Subsequent runs — respects the lock file, won't upgrade
terraform init
# Upgrade — finds newer versions within your constraints, updates lock file
terraform init -upgrade
The critical rule:
terraform init + lock file exists → uses exactly those locked versions
terraform init -upgrade → re-resolves within constraints → may update lock file
This distinction matters most in CI/CD. In your pipeline, you should run terraform init (without -upgrade) to use the locked versions. If you use -upgrade in CI, every pipeline run potentially gets different provider versions — defeating the entire purpose of the lock file.
Upgrades should be deliberate. A team member runs terraform init -upgrade locally, reviews the changes, tests them, and commits the updated lock file. The rest of the team and CI pick up the new versions through Git.
Hands-On: Upgrading a Provider
Before: Your lock file pins the AWS provider at 5.31.0. A security fix ships in 5.31.5.
Config:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.31" # Allows 5.31.x
}
}
}
Command:
terraform init -upgrade
Result: Lock file is updated to 5.31.5. You review the diff, run terraform plan to confirm nothing unexpected changes, and commit the updated lock file. Your team gets the security fix on their next git pull.
Multiple Providers: Version All of Them
Real configs have multiple providers. Version-constrain every single one:
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
If you don’t specify a version constraint for a provider, Terraform resolves the latest available version at init time. This is non-deterministic — what’s “latest” today isn’t the same as “latest” next month. Always specify constraints for every provider, even the ones that seem harmless like random or null. I’ve seen random provider v4.0 break production pipelines because it removed an argument that was used in three modules.
Checking What’s Installed
# Check your Terraform CLI version and platform
terraform version
# View the lock file to see pinned provider versions
cat .terraform.lock.hcl
# Verbose init output shows resolved versions
terraform init -upgrade
Design Scenario: The Broken CI Pipeline
Context: A team’s CI pipeline runs terraform plan on every pull request. On Monday everything works. On Tuesday, the random provider releases v4.0 with breaking changes. The pipeline breaks with an error about an unknown argument.
Root cause: No version constraint was set for the random provider. terraform init in CI grabbed the latest version — v4.0 — which removed an argument the config uses.
The fix:
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6" # Pins to 3.x — 4.0 is excluded
}
}
Commit the updated lock file. Pipeline is stable again.
The lesson: Unpinned providers are ticking time bombs. They work today, break tomorrow, and the breakage has nothing to do with your code changes. The fix is always the same: version constraints for every provider, lock file committed to Git, and -upgrade only when intentional.
Key Takeaways
Three things always go in the terraform {} block: required_version, required_providers, and the backend configuration. Think of it as the “config for the tool itself” — versioning, dependencies, and state storage.
The lock file’s purpose is identical to package-lock.json in Node or go.sum in Go: it makes builds deterministic. Without it committed to version control, every terraform init across your team resolves independently, and you lose reproducibility.
The mental model: required_providers specifies the range of acceptable versions. The lock file specifies the exact version currently in use. terraform init honors the locked version. terraform init -upgrade re-resolves within the acceptable range. Upgrades are deliberate, not automatic.

