The problem: versions move whether you are ready or not
Terraform, the azurerm provider and the modules you use all release new versions every few weeks. Most releases are harmless. Some rename arguments, change defaults, or write state in a newer format. If versions move by accident
- a teammate installs a newer Terraform, CI grabs the latest provider - you find
out in the middle of an unrelated change, usually in production.
So platform teams pin every version, and move them deliberately: one PR, a plan against every environment, dev first.
What you need to know already: version constraints (~>, >=) and the lock file .terraform.lock.hcl (12.3, 12.4), module version and ?ref= (13.38), the environments layout (14.1), terraform validate (14.6), PRs and pipelines (14.15).
Versions are written major.minor.patch (semantic versioning): 4.14.0 is major 4, minor 14, patch 0. A patch fixes bugs, a minor adds features, a major may break things - rename or remove arguments, change defaults.
Three things that get upgraded
Terraform itself required_version, the binary in CI and on laptops
providers required_providers constraints + .terraform.lock.hcl
modules version / ?ref= per call
Each has its own pin and its own way of moving. The discipline is the same: move deliberately, in a PR, with a plan against every environment.
Pinning Terraform
terraform {
required_version = "~> 1.9.0"
}
~> 1.9.0allows 1.9.x patch releases only (>= 1.9.0and< 1.10.0) - the tightest sensible pin for a root configuration.- A Terraform binary outside the range refuses to run (Unsupported Terraform Core version). That stops a teammate on 1.12 from quietly writing the state first.
- State is forward-only. Once a newer Terraform writes a state, older binaries refuse to read it:
Error: Error loading state: state snapshot was created by Terraform v1.10.2, which
is newer than current v1.9.8; upgrade to Terraform v1.10.2 or greater to work with
this state
That is the classic "someone ran apply from a laptop with a newer Terraform and now CI is broken". The fix is to upgrade CI - there is no downgrade.
- Pin the binary itself too: in CI (the
hashicorp/setup-terraformstep withterraform_version, 14.15) and on laptops with a version manager -tfenv,asdformise, tools that install several Terraform versions side by side and pick one per folder, usually from a.terraform-versionfile.
Upgrading Terraform: read the changelog (the list of what changed in each release). 1.x promises that configuration keeps working, but deprecations and new warnings appear. Bump required_version and the CI pin together, run init, validate and a plan in every environment - no changes expected - then merge.
Pinning providers
In the root configuration:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.14"
}
}
}
plus the committed .terraform.lock.hcl, which records the exact version chosen (e.g. 4.14.0) and its checksums. ~> 4.14 allows 4.14 and later 4.x, but not 5.0.
Modules state a minimum only (>= 4.0): a configuration can load just one version of each provider, so if every module pinned tightly they would conflict. The root chooses.
Minor and patch upgrades
terraform init -upgrade # newest version the constraints allow
git diff .terraform.lock.hcl # what moved
terraform plan # every environment: expect no changes
terraform init -upgradeignores the version currently in the lock file and picks the newest one the constraints allow, then rewrites the lock file.git diff .terraform.lock.hclshows what moved - the version and the hashes.terraform planin every environment should say No changes.
Automate the boring part: Dependabot (GitHub's) and Renovate (a similar open-source bot) watch for new versions and open PRs that bump the constraint and the lock file. CI runs the plans; a human reads them.
Major upgrades (azurerm 3.x -> 4.0)
A major version may rename, remove or change defaults. The azurerm 4.0 upgrade guide (a page in the provider docs listing every breaking change) includes, among many others:
provider subscription_id is required (or ARM_SUBSCRIPTION_ID)
skip_provider_registration -> resource_provider_registrations
storage account enable_https_traffic_only -> https_traffic_only_enabled
cluster (AKS) automatic_channel_upgrade -> automatic_upgrade_channel
node_os_channel_upgrade -> node_os_upgrade_channel
(The last two are arguments of the cluster resource from 14.2: how its software and its machines' operating system get updates. Only the names changed.)
The procedure:
- Read the upgrade guide for the major version, top to bottom.
- Create a branch; widen the constraint (
~> 4.0or~> 4.14); runterraform init -upgrade. - Run
terraform validateandplan. Renamed arguments fail loudly:
│ Error: Unsupported argument
│
│ on main.tf line 18, in resource "azurerm_storage_account" "logs":
│ 18: enable_https_traffic_only = true
│
│ An argument named "enable_https_traffic_only" is not expected here.
- Fix them. Plan every environment. The target is No changes: the same values under new names. Anything that wants to replace a resource is a migration you have not finished - read the guide's section for that resource.
- Merge constraint + lock file + fixes together. Upgrade dev first, prod last.
The lab enforces the renames above against the version recorded in the lock file, so 3.x names fail on 4.x and the other way round (simulator: only these renames are modelled).
Multi-platform lock files
The lock file records a checksum per platform - operating system plus CPU type, like darwin_arm64 (a Mac with Apple Silicon) or linux_amd64 (a normal Linux server). A lock file created on your Mac only contains the Mac checksums, so a Linux CI runner may fail to verify the provider it downloads. Record all the platforms you use, once:
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_arm64 \
-platform=windows_amd64
terraform providers lock downloads the provider for each -platform listed and writes all their checksums into the lock file.
Modules
- Registry modules:
version = "~> 2.1"or an exact"2.1.3";init -upgrademoves within the constraint. - Git modules:
?ref=v2.1.3or a commit hash; upgrading means editing the ref. - A new major version of a module is breaking: read its CHANGELOG, expect renamed inputs and
movedblocks (13.46), plan everywhere. - Modules are not in the lock file - the pin in the code is the only pin.
A checklist for any upgrade PR
- What moved: Terraform, a provider, a module? From which version to which?
- The changelog / upgrade guide link is in the PR description.
- Lock file diff included (providers).
- Plan output for every environment attached; each is No changes, or every change is explained.
- Rollout order: dev -> test -> prod, with a pause to watch.
What the errors look like
Raise the constraint and run plain init (without -upgrade): the lock file still says 3.117.1, and init tells you what to do.
│ Error: Failed to query available provider packages
│
│ Could not retrieve the list of available versions for provider
│ hashicorp/azurerm: locked provider registry.terraform.io/hashicorp/azurerm
│ 3.117.1 does not match configured version constraint ~> 4.14; must use
│ terraform init -upgrade to allow selection of new versions
After init -upgrade, the lock file diff is the record of what moved:
provider "registry.terraform.io/hashicorp/azurerm" {
- version = "3.117.1"
- constraints = "~> 3.117"
+ version = "4.14.0"
+ constraints = "~> 4.14"
hashes = [
- "h1:...",
+ "h1:...",
Lines starting with - were removed, + added: the version went from 3.117.1 to 4.14.0, the constraint changed, and the checksums are new.
Then validate shows every renamed or removed argument at once - which is the work list for the upgrade PR.
Terraform CLI upgrades in practice
# .terraform-version (read by tfenv; mise and asdf have equivalents)
1.9.8
tfenv install # installs the pinned version
terraform version # Terraform v1.9.8 on darwin_arm64
.terraform-version is a one-line file with the version number. tfenv install (with no argument) installs the version that file names; terraform version prints which binary you are actually running and on which platform.
terraform {
required_version = "~> 1.9.0" # root: tight
}
To move to 1.12: bump .terraform-version, the CI pin and required_version together in one PR; run init, validate and plan in every environment (expect No changes); merge. The first apply with 1.12 upgrades that state's format for good - which is why every pipeline that touches a state must be on 1.12 before any of them applies with it.
Renovate and Dependabot for Terraform
Both bots understand required_providers, module version / ?ref= and the lock file. A good setup:
- groups provider updates per root module, one PR each;
- runs the pipeline's plan stage on the PR, so the upgrade PR shows the plan for every environment;
- auto-merges nothing that touches prod - patch updates of dev-only tooling at most.
What you can now do:
- Pin Terraform, providers and modules, and say which file or setting holds each pin.
- Run a major provider upgrade:
init -upgrade, read the lock diff, fix whatvalidatereports, plan to No changes, dev first. - Explain why state is forward-only and what that means for CI.