Managing Terraform & Provider Versions

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:

FieldWhat it meansWhy it matters
versionThe exact version that was selected and installedGuarantees everyone uses the same version
constraintsYour constraint from required_providersDocuments what versions are acceptable
h1: hashesSHA-256 hashes of the provider binaryVerifies integrity — prevents supply-chain attacks
zh: hashesHashes of the zip archivesUsed 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.