Git, CI/CD & GitOpsLXVII · Dependency PinningIacPinning
Terraform provider and module pinning — version constraints and the registry lock
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
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:
- Identify the upgrade. Read the upstream release notes and advisories.
- Run
terraform init -upgrade. The init picks the new version and updates the lock file with the new hashes. - Review the diff. The diff shows the old version, the new version, and the new hashes. The review is the human link.
- Run
terraform planagainst the new provider. The plan must be reviewed for unexpected resource changes. - 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
- Declare version constraints in every
required_providersblock. No unconstrained providers. - Pin modules by exact version.
version = "5.5.1", notversion = "~> 5.0". - Commit
.terraform.lock.hclto the repository. The lock file is the source of truth. - Run
terraform init -lockfile=readonlyin CI. A pipeline that can silently upgrade providers is a pipeline that lost reproducibility. - 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
Q1. What is the difference between a version constraint in `required_providers` and an entry in `.terraform.lock.hcl`?
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.
Q3. Explain the role of the `h1:` hash in the `.terraform.lock.hcl` file.
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.