Terraform in Production · Pipelines and Scale
The Unpinned Module That Broke Prod
Unpinned modules and loose provider constraints change under you. Pin versions, commit the lock file and upgrade on purpose.
Scenario
Example team: every service team uses the platform team's shared ecs-service module from Git and its own AWS provider constraint. Nobody has changed the payments stack in a month.
What you see in production
A one-line change to the payments service plans dozens of unexpected changes: renamed security groups, replaced target groups and a new IAM policy format. The platform team merged a breaking refactor to the module's main branch last week, and a CI runner without a cached lock file picked up a new major AWS provider version.
The naive code
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0" # also allows 6.x, 7.x ...
}
}
}
module "api" {
source = "git::https://github.com/acme/terraform-modules.git//ecs-service?ref=main" # moves every merge
}
and .terraform.lock.hcl listed in .gitignore.
Reproduce it
How version constraints resolve as new releases appear (the pessimistic operator ~> allows only the rightmost version part to increase):
def parse(v):
return tuple(int(x) for x in v.split("."))
def allows(constraint, version):
op, want = constraint.split()
v, w = parse(version), parse(want)
if op == ">=":
return v >= w
if op == "=":
return v == w
if op == "~>": # ~> 5.40 means >= 5.40, < 6.0 ; ~> 5.40.0 means >= 5.40.0, < 5.41.0
upper = w[:-1][:-1] + (w[:-1][-1] + 1,)
return v >= w and v[:len(upper)] < upper
raise ValueError(op)
releases = ["5.40.0", "5.40.2", "5.82.1", "6.0.0", "6.14.0", "7.1.0"]
for constraint in [">= 5.0", "~> 5.40", "~> 5.40.0", "= 5.82.1"]:
ok = [r for r in releases if allows(constraint, r)]
print(f"{constraint:10} -> {ok}")
Output
>= 5.0 -> ['5.40.0', '5.40.2', '5.82.1', '6.0.0', '6.14.0', '7.1.0'] ~> 5.40 -> ['5.40.0', '5.40.2', '5.82.1'] ~> 5.40.0 -> ['5.40.0', '5.40.2'] = 5.82.1 -> ['5.82.1']
Root cause
Two kinds of moving dependencies were left unpinned. A Git module with ref=main is whatever the branch points to at init time. A provider constraint like >= 5.0 accepts the next major version, and without a committed .terraform.lock.hcl, every fresh terraform init can pick a newer release. The configuration didn't change, but the code that interprets it did.
The fix
terraform {
required_version = "~> 1.17"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.14" # minor and patch updates only; majors are a deliberate change
}
}
}
module "api" {
source = "app.terraform.io/acme/ecs-service/aws" # a registry module (private or public)...
version = "~> 3.2" # ...pinned with a version constraint
}
module "network" {
source = "git::https://github.com/acme/terraform-modules.git//network?ref=v2.4.1" # Git: pin a tag
}
- Commit
.terraform.lock.hcl. It pins exact provider versions and checksums; onlyterraform init -upgradechanges it. - Record hashes for every platform your team and CI use:
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64. - Release shared modules with semantic versions and a changelog; consumers upgrade with a PR whose plan shows the effect.
- Automate upgrade PRs (Dependabot or Renovate both understand Terraform) so pinned doesn't mean forgotten.
Trade-offs
- Tight pins mean regular upgrade PRs; skipping them for a year turns one upgrade into many breaking changes at once.
- Exact module pins (
= 3.2.0) are the most predictable;~>minor ranges accept fixes automatically but trust module authors to follow semver. - The lock file covers providers only, so modules still need explicit versions or tags.
Detect it in production
- CI fails if
.terraform.lock.hclchanges outside an upgrade PR (terraform init -lockfile=readonly). - Lint for module sources with
ref=main, branch names or noversion. - Report which provider and module versions each stack uses, and how far each one is behind.
Key points
- Unpinned modules and open-ended provider ranges change your infrastructure without a code change.
- Pin modules to versions or tags, constrain providers with
~>, and commit the lock file. - Upgrade on purpose, in small PRs, with plans reviewed like any other change.
Practice questions
What versions does `version = "~> 5.40"` allow, and what does `~> 5.40.0` allow?
Answer
~> 5.40 allows >= 5.40 and < 6.0. ~> 5.40.0 allows >= 5.40.0 and < 5.41.0 (patch releases only). Only the rightmost specified part may increase.
What does `.terraform.lock.hcl` pin, and should it be committed?
Answer
It records the exact provider versions and package checksums chosen by terraform init. Commit it so every run uses the same providers; it doesn't cover modules, which need their own version pins.
How do you pin a module that comes from Git?
Answer
Use ?ref= with an immutable tag (or a commit SHA) in the source address, such as ?ref=v2.4.1, instead of a branch like main.
Open in the app to mark lessons complete, take notes and track your progress.