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.

Create a free account to save this page, take notes and track your progress. Sign in

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.

Was this page helpful?