Your Terraform Provider Is Pinned. It’s Still Waiting to Break You

I want to talk about the gap between the Terraform you wrote on day one and the Terraform you’re still running two years later, because that gap is where most of the pain in a mature platform actually lives. Not in the initial build. In everything that happens to your modules and providers after nobody’s looking anymore.

Terraform provider version pinning sounds like the obvious answer to this problem, but a pin only controls when change reaches you. It doesn’t remove the need to manage that change.

AzureRM 5.0 is a good, current example of exactly what I mean.

What Actually Broke in AzureRM v5?

HashiCorp shipped AzureRM 5.0 as a major release on 27 July 2026, and it’s not a small one. The change most likely to catch teams out isn’t a renamed property, it’s a default behaviour flip: resource_provider_registrations now defaults to none instead of legacy. Previously, the provider would quietly register somewhere around 60 Azure Resource Providers on your behalf at startup. As of v5, it registers zero unless you tell it otherwise. If your subscription was relying on that implicit registration, and plenty were without realising it, your next apply can fail against a Resource Provider that was never explicitly registered. (GitHub)

The second one that’ll bite you if you’re not looking: address_space on azurerm_local_network_gateway changed type from a List to a Set. That means it’s now unordered, and if you had configuration referencing entries in that collection by index, that reference is no longer valid. (GitHub)

Neither of these is a bug. They’re both intentional, documented changes, and HashiCorp’s upgrade guide covers them clearly. That’s not really the point. The point is that a provider bump most teams treat as routine maintenance can take your pipeline down if there’s no process sitting between “new version exists” and “we’re running it in production.”

And major releases aren’t the only place I’ve seen this happen.

We ran into exactly this during the v4 lifecycle. We initially treated staying inside the same major release as relatively low risk, but later v4 releases still introduced changes that affected existing configurations. AzureRM 4.10.0, 4.14.0, 4.16.0 and 4.26.0 all contain changes explicitly labelled as breaking in the provider changelog. One changed a property from a List to a Set, others changed Azure API behaviour or made previously configurable properties read-only. Later v4 releases also introduced property migrations and renames. (GitHub)

We eventually stopped allowing anything within the v4 major line and pinned to the exact release we had tested.

That’s an important distinction. ~> 4.0 looks controlled because it prevents Terraform jumping to v5, but it still permits anything in the 4.x line. That’s a much wider blast radius than it first appears. For a mature production platform, I prefer an exact version and a deliberate process for moving it.

That process is what the rest of this post is about.

Pinning Is the Floor, Not the Whole Answer

If you’re not pinning your provider versions, start there. And by pinning, I mean the exact version you’ve actually tested:

terraform {

required_providers {

azurerm = {
  source  = "hashicorp/azurerm"
  version = "= 5.2.0"
}

}

}

provider “azurerm” {

features {}

}

At the time of writing, 5.2.0 is the current AzureRM release. (GitHub)

That exact constraint is deliberate. Compare it with:

version = “~> 5.0”

That constraint allows any 5.x release below 6.0. Terraform’s pessimistic constraint operator is useful, but it isn’t the same thing as saying “run the provider version we’ve tested.” ~> 5.2.0, for example, narrows that to the 5.2.x patch line, while = 5.2.0 means exactly what it says: this version and no other. (HashiCorp Developer)

And commit .terraform.lock.hcl.

The provider constraint controls which versions Terraform is allowed to select. The dependency lock file records the exact provider version Terraform actually selected, together with the package hashes used to verify it. HashiCorp recommends committing that file to source control so provider changes go through the same review process as the rest of your configuration. (HashiCorp Developer)

That gives you two controls: the configuration says what you permit, and the lock file records what you’re actually running.

Pinning stops a provider from silently upgrading underneath you between runs. But I think it gets treated as the finish line when it’s really just the floor. A pin that never moves is its own kind of debt. You’re not exposed to new breaking changes anymore, but you’re also not getting security fixes, bug fixes or new resource support, and the gap between what you’re running and what’s current only grows.

Eventually someone has to close that gap in one go, across however many releases have piled up, which is a far worse day than doing it incrementally would have been.

Building an Update Cadence With Actual Change Control

Pinning buys you the time to upgrade deliberately instead of by accident. What you do with that time is the part that actually matters.

The process I’d put around every provider bump:

  1. Read the changelog and upgrade guide before touching anything. Don’t only do this for major versions. Our experience with v4 is exactly why. If the changelog says a minor release changes a schema, validation rule or underlying Azure API version, I want to know before Terraform does.
  2. Update the version constraint and lock file together. Run terraform init -upgrade, review the resulting .terraform.lock.hcl change and make it part of the pull request.
  3. Test the bump in a non-production environment first. Run a plan, not just an apply, and actually read the diff. A clean plan against an environment already running the previous pinned version is the signal you’re looking for before you touch anything higher up the chain.
  4. Roll it out in stages, dev, then staging, then production, rather than bumping the pin everywhere in one pull request.
  5. Treat the version bump like any other change, pull request, review, plan output attached, approval before merge. It’s still infrastructure change. It doesn’t get a pass just because the obvious configuration diff is one line in a required_providers block.

I also like having something that tells you when you’re falling behind, rather than finding out when a breaking release lands and you’re several releases behind.

You can use tooling such as Dependabot or Renovate for this, but the underlying mechanism is simple enough to see clearly. A scheduled workflow can compare the provider recorded in your lock file with the current Registry version and raise an issue when there’s a gap:

name: Provider Version Check

on:

schedule:

- cron: '0 8 * * 1' # every Monday at 08:00 UTC

workflow_dispatch:

permissions:

contents: read

issues: write

jobs:

check-provider-version:

runs-on: ubuntu-latest
steps:
  - uses: actions/checkout@v7
  - name: Get current azurerm version
    id: current
    run: |
      version=$(awk '
        /provider "registry.terraform.io\/hashicorp\/azurerm"/ {
          in_provider=1
          next
        }
        in_provider && /version[[:space:]]*=/ {
          gsub(/"/, "", $3)
          print $3
          exit
        }
      ' .terraform.lock.hcl)
      test -n "$version" || {
        echo "AzureRM was not found in .terraform.lock.hcl"
        exit 1
      }
      echo "version=$version" >> "$GITHUB_OUTPUT"
  - name: Get latest azurerm version
    id: latest
    run: |
      latest=$(
        curl -fsSL \
          https://registry.terraform.io/v1/providers/hashicorp/azurerm/versions |
        jq -r '.versions[].version' |
        grep -Ev '[-+]' |
        sort -V |
        tail -1
      )
      test -n "$latest" || {
        echo "Could not determine latest AzureRM version"
        exit 1
      }
      echo "version=$latest" >> "$GITHUB_OUTPUT"
  - name: Create or update issue if behind
    if: steps.current.outputs.version != steps.latest.outputs.version
    uses: actions/github-script@v9
    env:
      CURRENT_VERSION: ${{ steps.current.outputs.version }}
      LATEST_VERSION: ${{ steps.latest.outputs.version }}
    with:
      script: |
        const title = 'azurerm provider update available';
        const body =
          `The scheduled provider check found that AzureRM is behind.\n\n` +
          `Current: ${process.env.CURRENT_VERSION}\n` +
          `Latest: ${process.env.LATEST_VERSION}\n\n` +
          `Review the changelog and upgrade guide before updating.`;
        const issues = await github.paginate(
          github.rest.issues.listForRepo,
          {
            owner: context.repo.owner,
            repo: context.repo.repo,
            state: 'open',
            per_page: 100
          }
        );
        const existing = issues.find(
          issue => issue.title === title
        );
        if (existing) {
          await github.rest.issues.update({
            owner: context.repo.owner,
            repo: context.repo.repo,
            issue_number: existing.number,
            body
          });
        } else {
          await github.rest.issues.create({
            owner: context.repo.owner,
            repo: context.repo.repo,
            title,
            body
          });
        }

There’s deliberately no terraform init -upgrade in there.

I’m not asking a scheduled process to decide when my platform should consume a new provider. I’m asking it to make the gap visible. The decision to move from 5.2.0 to whatever comes next still goes through the changelog, testing, plan and approval process.

It also updates the same open issue rather than creating another copy of it every Monday, because alert fatigue is a fairly effective way of turning visibility back into invisibility.

The Same Discipline Applies to Your Own Modules

I’ve written before about tagging your own Terraform modules with Git tags and semantic versioning, so you can pin a module reference the same way you pin a provider:

module “network” {

source = “git::https://github.com/org/network-module.git?ref=v1.1.0”

}

Unlike providers, Terraform doesn’t currently record remote module version selections in .terraform.lock.hcl, so that explicit module version matters. (HashiCorp Developer)

That’s still the right pattern. What I’d add now is automating the tagging itself, because manually remembering to tag every stable release is exactly the kind of step that gets skipped when someone’s in a hurry.

If the repository uses squash merges and the resulting commit message follows Conventional Commits, you can derive the semantic version bump directly from the commit landing on main:

name: Auto Tag Release

on:

push:

branches:
  - main

permissions:

contents: write

jobs:

tag:

runs-on: ubuntu-latest
steps:
  - uses: actions/checkout@v7
    with:
      fetch-depth: 0
  - name: Determine version bump
    id: bump
    shell: bash
    run: |
      commit_message=$(git log -1 --pretty=%B)
      if echo "$commit_message" | grep -qE \
        'BREAKING CHANGE:|^[a-zA-Z]+(\([^)]+\))?!:'; then
        bump=major
      elif echo "$commit_message" | grep -qE \
        '^feat(\([^)]+\))?:'; then
        bump=minor
      elif echo "$commit_message" | grep -qE \
        '^fix(\([^)]+\))?:'; then
        bump=patch
      else
        bump=none
      fi
      echo "bump=$bump" >> "$GITHUB_OUTPUT"
  - name: Create semantic version tag
    if: steps.bump.outputs.bump != 'none'
    shell: bash
    env:
      BUMP: ${{ steps.bump.outputs.bump }}
    run: |
      latest_tag=$(
        git tag --list 'v*' |
        grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
        sort -V |
        tail -1
      )
      latest_tag=${latest_tag:-v0.0.0}
      version=${latest_tag#v}
      IFS='.' read -r major minor patch <<< "$version"
      case "$BUMP" in
        major)
          major=$((major + 1))
          minor=0
          patch=0
          ;;
        minor)
          minor=$((minor + 1))
          patch=0
          ;;
        patch)
          patch=$((patch + 1))
          ;;
      esac
      new_tag="v${major}.${minor}.${patch}"
      git config user.name "github-actions[bot]"
      git config user.email \
        "41898282+github-actions[bot]@users.noreply.github.com"
      git tag "$new_tag"
      git push origin "$new_tag"

The convention is straightforward:

  • fix: produces a patch release.
  • feat: produces a minor release.
  • fix!: or feat!: produces a major release.
  • feat(network)!: also produces a major release.
  • A BREAKING CHANGE: footer produces a major release regardless of commit type.
  • Documentation, chore and other commits don’t create a release.

That convention needs everyone contributing to the module to actually follow it, so it’s worth documenting in the module’s README and enforcing through your pull request process.

The squash-merge part matters too. If you’re allowing arbitrary merge commits, git log -1 may just see Merge pull request #123 rather than the Conventional Commit you were expecting. Standardising the merge strategy makes the automation deterministic rather than clever.

Once that’s in place, every qualifying merge to main produces a properly versioned, properly tagged release without anyone having to remember to run git tag by hand.

And there’s another dependency hidden in both workflow examples: the GitHub Actions themselves.

I’ve used major-version tags above to keep the examples readable, but in a security-sensitive production repository I’d pin third-party Actions to their full commit SHA. A tag such as @v9 is still a mutable reference. GitHub recommends a full-length commit SHA when you need an immutable Action reference.

That’s the same principle again. Dependency governance doesn’t stop at Terraform.

Your modules end up governed by the same discipline you’re applying to your providers, and the whole chain, provider version, lock file, module tag, update cadence, stops depending on somebody remembering to do the right manual step at the right time.

That’s really the throughline here.

A breaking provider release isn’t the problem. Neither, as we learnt with AzureRM v4, is the major version boundary some magical line where all breaking behaviour lives.

The problem is consuming change you haven’t deliberately chosen.

An unpinned provider can change underneath you. A broadly constrained provider can move further than you expected. An exact pin that nobody reviews eventually becomes stale. A module pointing at a branch has no stable release boundary at all.

The answer isn’t to stop upgrading.

It’s to know exactly what you’re running, know when something newer exists, and have a repeatable process for deciding when you’re ready to move.

Your subscription could not be saved. Please try again.
Your subscription to deniscooper.co.uk has been successful.

Join Cloud Engineers Who Build Smarter on Azure

Free checklists, practical guides and the latest posts — built for cloud engineers working with Azure and Terraform.

Leave a Comment