Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 070 / 100

Terraform upgrade strategy: patterns, pitfalls and fixes

Keep Terraform and providers current.

Expona Admin5 min read

Introduction

Terraform and its providers are constantly evolving, bringing performance improvements, new features, and critical security patches. Yet many teams delay upgrades until they're forced to, or rush through them without a plan, leading to failed deployments and frustrated on-call engineers. A solid Terraform upgrade strategy: patterns, pitfalls and fixes is the difference between smooth transitions and production incidents.

In this post, we'll walk through the patterns that senior teams use to keep their infrastructure-as-code fresh and safe, the pitfalls that catch teams off guard, and the concrete fixes we've deployed across production environments. Whether you're managing tens of modules or hundreds, these practices scale.

Pin versions and upgrade deliberately

The first pattern is the most obvious and the most often skipped: pinning Terraform and provider versions explicitly. When versions float, two engineers running terraform plan may see different results, and your CI/CD pipeline may behave differently than your laptop. This ambiguity is the enemy of reliable infrastructure.

Pin Terraform itself in your root module's required_version block, and pin providers in required_providers:

hcl
terraform {
  required_version = "~> 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.30.0"
    }
    postgresql = {
      source  = "cyrilgdn/postgresql"
      version = "~> 1.20.0"
    }
  }
}

The tilde operator ~> locks the major and minor version but allows patch upgrades. This is deliberate: it lets you pick up bug fixes without exposing yourself to breaking changes. When you decide to upgrade, you change the constraint explicitly, test it, and commit that decision to version control.

Many teams we've worked with use = for even stricter pinning, especially in production. That's a valid trade-off: you gain predictability but lose automatic patches. Choose based on your risk tolerance and release cadence.

Read provider changelogs

Before upgrading any provider, read its changelog. This sounds like boilerplate advice, but it's where most surprises live. Providers frequently introduce new required arguments, change default behaviors, or deprecate attributes. The AWS provider especially can have dozens of breaking changes between minor versions.

For example, a recent AWS provider version required all S3 bucket configurations to explicitly set bucket_prefix rather than deriving it from the bucket name. A team that skipped the changelog would run terraform plan after the upgrade, see unexpected changes, and either revert or scramble to understand what broke.

Here's a concrete pattern:

  1. Visit the provider's GitHub releases page or HashiCorp registry.
  2. Read the changelog for the version you're upgrading to and any intermediate versions.
  3. Search for the word "breaking" or "deprecated" in your infrastructure code.
  4. Run terraform plan in a sandbox environment and review output carefully.

A team in a fintech platform we supported discovered that upgrading the PostgreSQL provider would have renamed internal computed attributes. That change would not have broken their Terraform state, but it would have confused monitoring queries that referenced those attributes by name. Reading the changelog first saved them hours of debugging.

Upgrade non-prod first

Never upgrade Terraform or a provider first in production. Always test in a non-production environment that mirrors production as closely as possible: same module structure, same resource types, preferably the same state size and complexity.

The pattern looks like this:

  1. Create a feature branch and update the version pin.
  2. Run terraform init -upgrade and terraform plan in a staging or development environment.
  3. Review the plan output line by line. Look for resource recreations, attribute changes, or unexpected deletions.
  4. Apply the changes if the plan is clean, or debug if there are surprises.
  5. Soak-test the upgraded infrastructure for a few days or a sprint.
  6. Only then, upgrade production via the same pull request pattern.

In a travel platform, a team skipped this step when upgrading the Kubernetes provider. A subtle change in how it managed ConfigMaps caused a rolling restart of all pods during the plan phase. Testing in staging first would have revealed that issue before production traffic was affected.

For larger organizations, consider a canary approach: upgrade one AWS region or one module first, monitor for a week, then proceed to others.

Commit the updated lock file

Terraform generates a .terraform.lock.hcl file when you run terraform init. This file records the exact versions of providers that were downloaded. It serves the same purpose as package-lock.json in Node.js or Gemfile.lock in Ruby: it guarantees reproducible builds.

When you upgrade a provider, a new lock file is generated. Commit this file to version control. This ensures that every engineer and every CI/CD pipeline uses the same provider versions, eliminating "works on my machine" issues.

The pattern:

hcl
$ terraform init -upgrade
# lock file is regenerated with new versions

$ git add .terraform.lock.hcl main.tf
$ git commit -m "upgrade aws provider to 5.35.0"
$ git push

Never commit .terraform/ itself—that's a directory containing downloaded provider binaries and should be in your .gitignore. But the lock file must be committed.

We've seen teams lose hours debugging state mismatches because one engineer had a different provider version locally and applied a different set of changes to production than what CI/CD later applied. The lock file prevents this completely.

Common mistakes

Upgrading multiple components at once: Resist the urge to upgrade Terraform, three providers, and modules all in the same PR. If something breaks, you won't know which change caused it. Upgrade one component, test thoroughly, merge, then move to the next.

Ignoring state backup: Before running terraform apply after an upgrade, ensure your state is backed up. If using S3 with versioning enabled, you're protected. If using local state, run terraform state pull > backup.tfstate manually. A provider bug could corrupt your state, and a backup is your insurance policy.

Skipping the lock file: Committing only the version constraints without the lock file defeats its purpose. Both files must go together into version control, every time.

Rushing through breaking changes: Some upgrades require code changes. Maybe a resource argument was removed or renamed. Don't skip these. Update your Terraform code first, validate it in a sandbox, then upgrade. Breaking changes are signals to pay attention, not roadblocks to work around.

Key takeaways

  • Pin Terraform and provider versions explicitly using required_version and required_providers. Use ~> for flexibility or = for strict reproducibility.
  • Always read provider changelogs before upgrading. Breaking changes and deprecations are documented; searching for them takes minutes and saves hours.
  • Test upgrades in non-production environments first. Run terraform plan, review the output, apply if safe, and soak-test before touching production.
  • Commit the .terraform.lock.hcl file to version control every time you upgrade. This guarantees that all team members and CI/CD pipelines use the same provider versions.
  • Upgrade one component at a time. Mixing Terraform, provider, and module upgrades makes debugging harder if something breaks.
  • Back up your state before applying changes after an upgrade. A provider bug is rare but not impossible, and state recovery is much easier with a backup.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles