Terraform provider version pinning is less about locking in a specific version and more about creating a version constraint that allows for controlled upgrades.

Let’s see this in action. Imagine we have a simple Terraform configuration for an AWS S3 bucket:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "example" {
  bucket = "my-unique-example-bucket-12345"
  acl    = "private"
}

Here, ~> 4.0 is a pessimistic constraint. It means "any version greater than or equal to 4.0 but less than 5.0". Terraform will happily pick 4.1.0, 4.5.2, or even 4.50.0 if it exists, but it will not upgrade to 5.0.0 without explicit intervention.

The Problem: Uncontrolled Provider Updates

When you define a provider without a version constraint (or with a very loose one like ">= 4.0"), Terraform’s terraform init command will fetch the latest available version that satisfies the constraint. This can be a problem because:

  1. Breaking Changes: New provider versions, especially major ones, can introduce breaking changes to resources or data sources. If your code relies on a specific behavior that changes, your terraform apply could fail or, worse, silently modify your infrastructure in unexpected ways.
  2. Inconsistent Environments: If multiple developers or CI/CD pipelines are working on the same Terraform codebase, they might end up with different provider versions installed. This can lead to "works on my machine" scenarios and subtle differences in how Terraform plans and applies changes.
  3. Security Vulnerabilities: Older provider versions might have known security vulnerabilities. Pinning to a specific range allows you to control when you adopt updates that patch these issues.

The Solution: Version Constraints

Terraform’s required_providers block is where you define these constraints. The syntax is crucial:

  • Exact Version (= "4.5.2"): This pins the provider to exactly version 4.5.2. No upgrades will happen automatically. You’ll need to manually change this to upgrade.
  • Pessimistic Constraint (~> 4.0): As seen above, this allows patch and minor version updates within the 4.x.y series but prevents major version upgrades (e.g., to 5.0.0). This is the most common and recommended approach for balancing stability with the ability to get bug fixes and minor feature improvements.
  • Pessimistic Constraint (~> 4.5): This allows patch updates within the 4.5.x series but prevents minor or major version upgrades (e.g., to 4.6.0 or 5.0.0).
  • Greater Than or Equal To (">= 4.0"): Allows any version from 4.0.0 onwards. This is generally discouraged for production environments due to the high risk of breaking changes.
  • Wildcard ("*" or ""): Allows any version. This is the least safe and should be avoided.

When you run terraform init with a version constraint, Terraform checks the available versions for the specified source (e.g., hashicorp/aws) and selects the latest version that satisfies your constraint. It then records this exact version in the .terraform.lock.hcl file.

The .terraform.lock.hcl File: Your Source of Truth

The .terraform.lock.hcl file is generated by terraform init and is critical. It records the exact versions of all providers (and Terraform itself) that were downloaded to satisfy your constraints.

# Example .terraform.lock.hcl
# terraform 1.5.7
# provider registry.terraform.io/hashicorp/aws v4.50.0

provider "aws" {
  constraints = ["~> 4.0"]
  hashes = [
    "..." # Cryptographic hashes for integrity
  ]
}

This lock file ensures that subsequent terraform init commands (or terraform apply that implicitly runs init) will download the exact same provider versions, regardless of what’s currently latest in the registry. This guarantees consistent environments across different machines and CI/CD runs.

Upgrading Providers: A Controlled Process

When you want to upgrade your providers (e.g., to get a new feature, fix a bug, or address a security vulnerability), you follow a deliberate process:

  1. Update the Version Constraint: Modify the version argument in your .tf files. For example, change ~> 4.0 to ~> 4.5.
  2. Run terraform init -upgrade: This command tells Terraform to re-evaluate the provider constraints, find the newest versions that satisfy the new constraints, and update the .terraform.lock.hcl file accordingly.
  3. Run terraform plan: Review the plan to ensure no unexpected changes are proposed due to the provider upgrade.
  4. Run terraform apply: Apply the changes, which often involves just updating the lock file but could involve resource changes if the new provider version has different behavior for existing resources.

This controlled upgrade process is key. You’re not blindly accepting whatever the latest version is; you’re explicitly deciding to move to a new version range and then verifying the impact.

The Counterintuitive Truth About Locking

The .terraform.lock.hcl file isn’t just a record; it’s the primary mechanism that enforces your version constraints after the initial init. When you run terraform init without -upgrade, Terraform consults the lock file first. If the lock file exists and the recorded provider versions still satisfy your required_providers block’s constraints, Terraform will use those locked versions. It only goes to the registry to find new versions if the lock file is missing, outdated, or if you explicitly use -upgrade. This means your lock file is your guarantee of reproducibility, even if a new provider version is released after your lock file was generated.

The next step in managing your Terraform ecosystem involves understanding how to manage different provider versions for different environments (e.g., dev vs. prod) using workspaces or separate state files.

Want structured learning?

Take the full Terraform course →