Skip to main content
RunBook Academy

Git, CI/CD & GitOpsLXVII · Dependency PinningIacPinning

Terraform provider and module pinning — version constraints and the registry lock

Advanced⏱ ~26 mingit

What you'll learn

  • Constrain Terraform providers and modules with version constraints in `required_providers` and `required_version`
  • Commit `.terraform.lock.hcl` and interpret its `h1:` checksum entries
  • Run `terraform init -lockfile=readonly` in CI to prevent accidental provider upgrades
  • Distinguish between version constraints (consumer intent) and lock file (selection evidence)

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

Terraform providers and modules are pulled from registries at every terraform init. The version constraint in required_providers declares the consumer intent — the range of versions the configuration is compatible with. The .terraform.lock.hcl file records the selection: the specific checksums the team reviewed. The lock file is committed to the repository and verified in CI; a plan that picks a different provider version than the lock file declares is rejected. Two controls, two roles, one discipline.

Version constraints declare intent

A Terraform configuration declares the providers it needs and the version ranges it accepts. The declaration is the consumer intent: “this configuration is compatible with provider X, versions in this range”.

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

The ~> 5.0 constraint means “any version in the 5.x line, but not 6.0”. The constraint is enforced by terraform init; the init fails if the registry has no satisfying version. The constraint is not a pin; the registry can publish a new 5.x version between two consecutive inits, and the init will pull the new version without any change to the configuration.

flowchart LR
    A["Version constraint"] --> B["Consumer intent"]
    B["Consumer intent"] --> C["Init resolves"]
    C["Init resolves"] -. "new version" .-> D["Different bytes today"]
    E["Lock file"] --> F["Selection evidence"]
    F["Selection evidence"] --> G["Same bytes forever"]

The dependency lock file freezes the selection

The .terraform.lock.hcl file records the exact checksums of the providers and modules the team reviewed. The lock file is committed to the repository; it is the source of truth for reproducible plans.

# .terraform.lock.hcl
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.31.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:abc123...",
    "h1:def456...",
  ]
}
provider "registry.terraform.io/hashicorp/random" {
  version     = "3.6.2"
  constraints = "~> 3.6"
  hashes = [
    "h1:789xyz...",
  ]
}

The h1: prefix is a Terraform-specific hash scheme (the SHA-256 of the provider’s zip archive, base64-encoded). The lock file records the hashes for every platform the team supports. A terraform init that finds a hash in the lock file but a different hash at the registry fails unless the team explicitly updates the lock.

The CI enforcement: -lockfile=readonly

A CI pipeline must run terraform init with the lock file marked read-only. The flag prevents an accidental provider upgrade from slipping into the plan; the pipeline fails if the registry has published a new version since the team last updated the lock.

# CI: lock file is read-only, plan uses only reviewed providers
terraform init -lockfile=readonly
terraform plan -lockfile=readonly -out=tfplan.binary

The -lockfile=readonly flag is the production control. A pipeline that runs terraform init without the flag is a pipeline that can pick up a new provider version at init time and silently include it in the plan.

Module pinning

Modules are pulled from the registry the same way providers are. The version constraint in the module block narrows the selection; the lock file freezes it.

module "network" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.1"
}

The version = "5.5.1" is an exact pin — an exact module version that the lock file records. A module referenced without a version is a module that resolves to whatever the registry has at init time; the lock file is the only thing that prevents drift.

Updating the lock file deliberately

A lock file update is a supply-chain change. The workflow is:

  1. Identify the upgrade. Read the upstream release notes and advisories.
  2. Run terraform init -upgrade. The init picks the new version and updates the lock file with the new hashes.
  3. Review the diff. The diff shows the old version, the new version, and the new hashes. The review is the human link.
  4. Run terraform plan against the new provider. The plan must be reviewed for unexpected resource changes.
  5. Commit the lock file update in a pull request. The PR contains the new lock file, the new provider version, the review notes, and a link to the upstream release.

A Dependabot-equivalent tool can automate step 2 and open the PR in step 5; the team still performs steps 3 and 4.

Production discipline

  1. Declare version constraints in every required_providers block. No unconstrained providers.
  2. Pin modules by exact version. version = "5.5.1", not version = "~> 5.0".
  3. Commit .terraform.lock.hcl to the repository. The lock file is the source of truth.
  4. Run terraform init -lockfile=readonly in CI. A pipeline that can silently upgrade providers is a pipeline that lost reproducibility.
  5. Update the lock file through review. A lock file bump is a supply-chain change.

Cross-course references

  • Git, CI/CD & GitOps — Part LXVII-01 (Pinning Discipline) defines the discipline that motivates provider pinning.
  • Git, CI/CD & GitOps — Part L-06 (Plan as Artifact) covers the plan review that locks the supply chain to the team’s intent.
  • Terraform for Production Sysadmins — Part XX (Module Trust) covers the same model applied to module provenance and signing.
  • Terraform for Production Sysadmins — Part IX-XII (State) covers the state file that the locked providers consume.

Quiz

Knowledge check · 4 questions

  1. Q1. What is the difference between a version constraint in `required_providers` and an entry in `.terraform.lock.hcl`?

  2. Q2. Running `terraform init` in CI without the `-lockfile=readonly` flag is safe as long as the team has committed `.terraform.lock.hcl` to the repository.

  3. Q3. Explain the role of the `h1:` hash in the `.terraform.lock.hcl` file.

  4. Q4. Identify the gap in the team's Terraform pinning practice and the rule that closes it.

    Team T runs 80 Terraform configurations across 80 repositories. Each repository has a `required_providers` block with `version = "~> 5.0"`. None of the repositories have committed a `.terraform.lock.hcl` file. The CI pipelines run `terraform init` without the `-lockfile=readonly` flag. A new minor version of the AWS provider (5.32.0) is published to the registry; the init in every pipeline picks up the new version silently. The new version contains a breaking change in the data source return type. Every pipeline's plan shows resource replacements that did not exist in the previous plan.

Passing score: 75%. Answers are checked in this browser.