Skip to main content
RunBook Academy

TerraformXXIV · Upgrading Terraform, Providers, and ModulesProduction Terraform

Upgrading Terraform Core

Intermediate⏱ ~14 minbash

What you'll learn

  • Read the Terraform Core release notes and identify breaking changes
  • Bump the `required_version` floor and upper bound deliberately
  • Choose the right cadence: patch automatic, minor deliberate, major rare
  • Stage the rollout: developer laptop, CI, staging, production
  • Recognise the production cost of an unattended Terraform Core bump

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

Terraform Core is the binary every operator runs. It is also the binary whose version is the most under-managed in production. The standard pattern is: install whatever apt serves, accept the constraint the last engineer wrote, and discover the breaking change on the next apply. This lesson is the procedure that prevents that.

The procedure is not complicated. The discipline is.

The two upgrades people confuse

When an operator says “I upgraded Terraform”, they may mean either of two things:

Terraform Core (this lesson)
  - the binary on every host
  - the .terraform-version file (tfenv, asdf)
  - the required_version constraint
  - cadence: deliberate, staged

Providers (next lesson)
  - the plugins that talk to APIs
  - the .terraform.lock.hcl file
  - the required_providers constraint
  - cadence: depends on the provider

Both are upgrade paths. Both have blast radius. They are managed independently and follow different cadences. A Core upgrade without a provider upgrade is normal; a provider upgrade without a Core upgrade is normal; mixing the two in the same pull request is the most common production mistake.

Read the release notes first

Every Core release ships with a changelog. The changelog has three sections; each one requires a different response.

1. NEW FEATURES. A new feature in 1.9.x, for example. Nothing changes for configurations that do not opt in. The plan output should be identical before and after the upgrade.

2. UPGRADE GUIDES. HashiCorp publishes an upgrade guide per minor release and per major release. The guides enumerate every behavioural change that could affect existing configurations. Reading the guide is not optional. Skim it once; mark any change that touches a resource type, a state format, or a CLI flag the team uses.

3. BUG FIXES. Patches only. No behavioural change for correct configurations. Still read the patch notes; a “bug fix” can expose a behaviour the team was relying on that the project now considers incorrect.

The location of the notes matters. For Terraform Core, the authoritative source is the GitHub releases page for hashicorp/terraform and the upgrade guide at developer.hashicorp.com/terraform/upgrade-guides. For OpenTofu, the equivalent page lives under opentofu.org/docs/upgrade-guides.

The cadence

Three tiers, three policies:

TierExampleCadenceProcedure
Patch1.9.0 → 1.9.8As soon as the patch is outRead the notes, test on dev, promote
Minor1.8 → 1.9Quarterly, after the team’s first release on the new lineFull staging apply, deliberate constraint bump
Major1.x → 2.xRare; treat as a projectMigration plan, dedicated runbook

Patches ship to fix bugs and CVEs. The release notes should be short; the changes should be confined. Auto-upgrading patches is acceptable for a team with a CI test that exercises a representative configuration against every version.

Minors ship to add features. The 1.8 → 1.9 transition, for example, graduated removed blocks to general availability and tweaked the provider plugin protocol. None of those are guaranteed backwards compatible. A team that jumps a minor without staging is gambling that the project honoured its own compatibility contract.

Majors are rare and deliberate. Terraform Core has not had a 2.0 release as of mid-2026. When it does, every operator will need a migration plan.

The constraint in required_version

The constraint is the contract. It has to match the binary the team runs and the cadence the team accepts:

terraform {
  required_version = ">= 1.9.8, < 1.10.0"
}

Three choices to make:

The lower bound is the floor. It should match the oldest binary still in the fleet. A team running 1.9.0 on a bastion host and 1.9.8 on laptops has a floor of 1.9.0, not 1.9.8. The team upgrades the bastion, then the floor.

The upper bound is the ceiling. It is what makes the upgrade an explicit event. A team that sets the upper bound at 1.10.0 has explicitly said “we are not on 1.10 yet”; an operator that installs 1.10 on their laptop sees terraform plan fail with “Unsupported Terraform Core version” rather than producing a different plan silently.

The operator shape controls how big a jump is allowed. ~> 1.9.0 admits 1.9.x patches and rejects 1.10.0. A team that wants to opt in to patches but defer minor bumps uses ~>. A team that wants explicit control over both bounds uses >= ..., < ....

The lesson on required_version: The Floor covers the operator semantics in detail. The summary here: stay inside a narrow range, bump deliberately, document the upgrade window.

The staged rollout

The binary lands on four classes of host, in order:

1. Developer laptop       (one operator)
2. CI runner              (every plan, no apply)
3. Staging backend        (apply against staging state)
4. Production runner      (apply against production state)

Each step has a different gate.

Step 1: developer laptop. Run terraform plan against a representative working directory. The plan should be a no-op. If it shows changes, stop; the upgrade is not a no-op and the rest of the rollout does not proceed.

Step 2: CI runner. The CI image is rebuilt with the new binary. The pipeline runs terraform plan against every working directory in the repo. The plan must match the plan from step 1. If it does not, the CI is observing a different behaviour, and the difference is the answer to “why is this not a no-op”.

Step 3: staging backend. Apply against the staging state. The state file is real production-shaped data. The apply must succeed without manual intervention. If it fails, the staging state is the recovery target: re-apply with the previous binary.

Step 4: production runner. Promote the CI image. Run the production pipeline. The plan output must match the CI plan output, which must match the developer-laptop plan output. Three identical plans, three different hosts.

A team that skips staging is gambling that the production state file is shaped like the dev state file. It is not.

What to watch for in the plan

Three plan patterns indicate the upgrade is not a no-op:

A. State version bump. Terraform Core can read any prior state version. It writes the current version on apply. A state version bump is normal and not a problem.

B. Attribute change in a resource. A new attribute the provider records. Safe to accept; the underlying resource did not change.

C. Resource replacement. Terraform proposes to destroy and recreate a resource. This is the dangerous one. Read the upgrade guide; understand why the replacement is necessary; only then accept. A forced replacement in production is an outage in the making.

The plan review skill is covered in the plan review module. The short version here: if the plan shows changes, the upgrade is not a no-op, and the rollout does not proceed.

Production failure modes

Six failure modes recur.

1. Unattended apt upgrade. Symptom: a host picks up the new Core version the next time apt upgrade runs; the upper bound is forgotten; the host now runs a version outside the constraint. Recovery: pin the package with apt-mark hold; bake the version into the golden image; revisit the constraint.

2. Constraint bumped without staging. Symptom: an engineer edits required_version to admit the new minor; the next production plan shows changes. Recovery: revert the constraint; run a full staging apply; only then re-bump.

3. HashiCorp yanks a release. Symptom: the team’s lock file pins a yanked version; init fails because the registry no longer serves it. Recovery: bump to the next patch; re-lock.

4. CI runner drift. Symptom: the CI image is rebuilt with the new binary; the developer fleet is still on the old binary; plans diverge. Recovery: roll the developer fleet to the new binary in the same change; never have a window where CI and laptops disagree.

5. Hidden behaviour change in a “patch”. Symptom: a patch release changes a default behaviour; the team’s plans now show unintended changes. Recovery: revert to the previous patch; read the patch notes again; if the change is intentional, set the attribute explicitly to preserve old behaviour.

6. Two operators, two binaries, one plan. Symptom: the same configuration produces different plans on different hosts because the binaries differ. Recovery: this is a discipline failure, not a tool failure; the team rolls forward to one binary in one change, with the constraint matching.

Security and performance

  • CVEs in the binary. A Core CVE is rare but happens. Subscribe to the HashiCorp security advisories; mirror the release-notices RSS into the team’s chat. A patch within 48 hours is the production default for an exploitable CVE.
  • disable_checkpoint. A Core upgrade may reset client defaults. The ~/.terraformrc is preserved across upgrades, but a custom-built image that loses the file on rebuild will start calling checkpoint.hashicorp.com again. Verify after every image rebuild.
  • Performance. Core performance changes are rare. A new release that introduces a 2x slowdown on plan is a bug, not a regression; report and downgrade.
  • Provider protocol. A Core bump can change the provider protocol version. The protocol is versioned; both Core and provider must speak the same protocol. terraform init will warn if the resolution fails; resolve before any apply.

Production guidance

  • Treat Core upgrades as explicit events. Even patches get a pull request, a plan output, and an apply.
  • Stay inside a narrow range. >= 1.9.8, < 1.10.0 is the default. Bumping the upper bound is the upgrade.
  • Match binary to constraint. Three pins: the package, the CI image, and the configuration. If any two diverge, the rollout is incomplete.
  • Read the upgrade guide. Every minor, every major. Patches can be skimmed.
  • Stage the rollout. Laptop, CI, staging, production. Never skip staging.
  • Hold the package. apt-mark hold terraform on production hosts until the team has tested the next minor.

What comes next

The next lesson is on upgrading providers safely — the version bump in required_providers, the lock-file refresh, and the test discipline that catches a breaking provider version before it reaches production.

Verification

# READ-ONLY: confirm the binary
terraform version
Terraform v1.9.8
on linux_amd64
# READ-ONLY: confirm the constraint matches
grep required_version *.tf
versions.tf:  required_version = ">= 1.9.8, < 1.10.0"

If the binary version is outside the constraint, the configuration is unsafe to apply until either the binary or the constraint is changed.

Knowledge check · 7 questions

  1. Q1. What is the recommended cadence for a Terraform Core minor upgrade (e.g. 1.9 → 1.10)?

  2. Q2. A patch upgrade to Terraform Core (e.g. 1.9.7 → 1.9.8) can be auto-applied without reading the release notes.

  3. Q3. Which host tier should receive the new Terraform Core binary first?

  4. Q4. What should the team do when the plan output after a Core upgrade shows a resource replacement?

  5. Q5. Which of the following are valid gates for a staged Terraform Core rollout? (Select all that apply.)

  6. Q6. A team sets required_version = '>= 1.9.0, < 2.0.0'. An operator upgrades their laptop to 1.10.0 without telling anyone. What happens on the next plan?

  7. Q7. What is the right way to keep a Terraform Core version in place across a fleet of Ubuntu hosts?

Passing score: 75%. Answers are checked in this browser.