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):

Which releases do these constraints accept?Try it yourself
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; only terraform init -upgrade changes 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.hcl changes outside an upgrade PR (terraform init -lockfile=readonly).
  • Lint for module sources with ref=main, branch names or no version.
  • 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.