Masterpoint stands with Ukraine. Here’s how you can help Ukraine with just a few clicks. >

The Ultimate Terraform Versioning Guide

By Veronika Gnilitska
A practical, no-fluff guide to versioning in Terraform/OpenTofu that helps you avoid the hidden traps of tool, provider, and module mismatches. If your team has ever faced surprises after “just updating Terraform” (or wants to prevent them), this guide is a must-read.
The Ultimate Terraform Versioning Guide

Table of Contents

At Masterpoint, we’re particular about versioning strategies in Terraform and OpenTofu (collectively referred to from here on out as TF). TF is powerful, but its flexibility introduces challenges. Incorrect or inconsistent versioning of the TF CLI, providers, or modules can quickly escalate from minor inconveniences into major problems, impacting both local development and automated pipelines.

Why Infrastructure Versioning Is Uniquely Complex

Infrastructure versioning carries risks that don’t exist in typical software development. When your web application has a bug, you can usually deploy a fix in minutes. But when TF breaks your infrastructure, you might find yourself in a state where you can’t even run TF to fix the problem.

The State Trap

Infrastructure state creates dependencies that regular applications don’t have. If a provider upgrade changes how it interprets existing resources, you could find yourself locked out of managing that infrastructure entirely. Unlike application code where you can always roll back, infrastructure often can’t be “rolled back” without significant downtime or even data loss.

The Cascade Effect

Infrastructure components are deeply interconnected. A networking change can break database connectivity, which breaks application deployment, which breaks monitoring, creating a cascade of failures that’s nearly impossible to untangle under pressure.

These challenges make infrastructure versioning less forgiving than traditional software versioning, which is exactly why the practices in this guide matter so much.

Common Issues Caused by Poor Versioning Practices

Imagine a team managing AWS Lambda functions with TF. Their setup used the cloudposse/terraform-aws-lambda-function module, and since everything had been working smoothly, they decided to update the module to the latest version, without pinning it.

What they didn’t realize was that the new module version introduced an internal change to how tags were passed to the CloudWatch submodule. This altered the naming of log groups, even though the team’s TF configuration hadn’t changed. When they ran plan, it showed something alarming: TF wanted to destroy and recreate the existing log groups (GitHub Issue #78, fix in v0.6.1). Had they applied the plan, they would’ve lost all historical logs for their Lambda functions – critical data for debugging, compliance, and audit trails.

Careless versioning can be a ticking time bomb. Symptoms range from confusing state file errors to providers introducing breaking changes that unexpectedly alter or even destroy resources. Let’s take a closer look.

  • State File Version Mismatches Running TF commands with different TF CLI versions between local machines and automated environments can cause state file errors. Older TF versions can reject state files created by newer versions, leading to halted apply runs and forcing teams into immediate, unplanned upgrades.

  • Provider Compatibility Problems Providers, such as AWS or AzureRM, regularly introduce breaking changes. Incorrect provider version constraints often manifest as configuration errors, unexpected resource replacements, or sudden plan failures.

    📝 For example, this issue in AWS Provider v5.86.0 broke backwards compatibility in the S3 lifecycle configuration: users saw unexpected behavior due to a change in handling the transition_default_minimum_object_size attribute.

    📝 In a different kind of failure, TF fails to read the state file because the aws_eks_addon resource contains the argument resolve_conflicts, which is no longer recognized by the current AWS provider.

    Terraform error showing unrecognized argument resolve_conflicts

    This typically happens when a provider was upgraded without properly updating the state, and the new provider version doesn’t recognize attributes written by a previous one. This happened because resolve_conflicts was deprecated in version 5.0.0 of the hashicorp/aws provider and completely removed from the documentation in version 6. If the state is last updated using an older provider version that is still supported for this argument, and then a newer version is used without refreshing or migrating the state, TF can’t decode the resource.

  • Module Compatibility Conflicts Modules built for older TF or provider versions can disrupt modern workflows. Using outdated modules can result in errors, such as incompatible syntax or missing resource arguments, which require immediate intervention or rewriting of the module code.

  • Pipeline and Automation Breakdowns Inconsistent TF CLI and provider versions in automation amplify the risk of failures. Pipelines may inadvertently upgrade providers or the TF CLI itself, introducing unexpected breaking changes. Such unplanned upgrades interrupt development cycles and delay critical deployments.

Here’s our lightweight guide to preventing the issues above and achieving effective version management.

Child Modules: Keep it Open

Since Child Modules are intended to be consumed repeatedly - by you or by others - you want to minimize any constraints they impose on Root Modules. Here’s how:

  • Use minimum versions for TF Version: Identify the earliest TF CLI and provider versions that your Child Module can work with.
  • Use the >= operator for provider versions: Ensure your Child Module requires only the minimum required provider versions. Set only the minimum version requirements, allowing Root Modules to choose a newer version. This enables teams that haven’t upgraded their provider versions yet to still use your module, while not blocking teams who aim to use newer versions.

How to determine the minimum required versions?

  1. Start with the latest stable versions Begin developing with the latest versions of Terraform and your provider (e.g., AWS). This gives you access to the most recent features and improvements.
  2. Track what features you’re using As you write resources, make note of any newer settings, arguments, or resources you’re using. Many of them were introduced in specific provider or CLI versions.
  3. Check when those features were added Use the following to trace back their origin:
    1. Provider changelog: e.g., AWS Provider Changelog. Look for the version where a particular feature was first mentioned.
    2. Use the TF Registry version selector: On the Terraform/OpenTofu Registries, you can view the documentation for any version of a provider. Switch to an older version and see if your resource argument is supported. If not, bump up to the next version until you find it.
    3. Use the TF Module Protocol Context (MCP) Servers: The MCP API (hashicorp/terraform-mcp-server and opentofu/opentofu-mcp-server) can programmatically query which versions support which features, making compatibility checks significantly faster. For critical cases, you may want to spot-check a few key results; however, MCP servers generally provide reliable information.
  4. Set your minimum versions accordingly Once you identify the earliest version that supports everything your Child Module uses, set that version in your required_providers and required_version blocks:
terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = ">= 5.0.0"
  }
}

Here, the Child Module only requires a minimum version (Terraform v1.6, AWS provider v5.0), allowing the Root Module that consumes this Child Module to run newer versions as they become available.

By setting only a lower bound (rather than pinning to an exact version), you give consuming Root Modules the flexibility to select newer TF and provider releases. In turn, they can upgrade at their own pace without needing to update their Child Modules.

Root Modules: Be Precise

Root modules are the entry point for the TF plan and apply operations, so they need to be more prescriptive to guarantee consistent behavior whenever you spin up a new instance (i.e., create a new state file). Here’s how to lock things down:

  • Explicitly pin TF CLI versions: Always use the latest stable version your Root Module supports. You’ll need to upgrade this version each time you want to use a new TF version across your codebase.
  • Use pessimistic constraints (~>) for providers: Identify the highest stable provider versions your Root Module supports, and define both lower and upper version constraints. This ensures you get patches without the risk of unexpected breaking changes that might come with minor or major upgrades.

Example:

terraform {
  required_version = "1.7.5"
  required_providers {
    aws = "~> 5.81.0"
  }
}

In this example, two things are happening:

  1. The TF CLI version is pinned exactly at 1.7.5, ensuring that your entire team uses the correct version with this Root Module.
  2. The AWS provider is pinned with ~> 5.81.0, which means it can update to 5.81.1, 5.81.2, etc., but not jump to 5.82.0.

If you’re more willing to use the bleeding edge of providers, you can always use the ~> operator on the minor version like so version = "~> 5.81". This will enable any new minor version updates and is essentially a shorthand for >= 5.81.0, < 6.0.

Providers break, and this approach can introduce frustrating bugs from providers that can affect your project. We have seen this break things before on our own internal projects (where we are less risk-averse) that follow this methodology. See this recent example if you’re interested in what this looks like: [Announcement]: AWS Provider version v6.1.0 has been removed from the Registry.

Handling Major Upgrades

While pessimistic constraints protect you from unexpected breaking changes, there comes a time when you need to deliberately upgrade to a new major version, whether for new features, security fixes, or long-term support considerations.

Typically, major upgrades are considered in the following cases:

  • Security vulnerabilities in your current major version
  • End-of-life announcements for your current version
  • Critical features only available in newer major versions
  • Dependency chains forcing upgrades (e.g., module requiring newer provider)

We recommend treating major upgrades as a regular, systematic part of your workflow, not a one-time task. We’ve seen teams spend far more time untangling outdated versions than they would have by automating upgrades and handling them consistently.

  1. Research first: Read the release notes, migration guide (if available), and check for any critical open issues reported after the major version release. Understand what could break and what manual changes are required.
  2. Coordinate across projects: Major provider upgrades often affect multiple Root Modules. Plan the rollout sequence – some modules may need updates before others can be upgraded.
  3. Stage the rollout: Start with non-critical environments, then gradually roll out to staging and production. Don’t upgrade everything at once.

Timing Your Upgrades: Bleeding Edge vs. Stability

Should you upgrade to new major versions immediately or wait for them to become stable? The answer depends on your organization’s risk tolerance and infrastructure criticality:

  • Early adopter (0-2 months): Suitable for non-critical environments, internal projects, teams that have solid automated TF testing in place, or when you need the latest features.
  • Conservative approach (3-6 months): Wait for the community to identify and fix initial bugs. Most production environments benefit from this approach. By 3-6 months, major issues are typically resolved, and migration patterns are well-documented.
  • Stability-first (6+ months): For highly critical infrastructure where downtime is extremely costly. Wait until the major version is widely adopted and proven stable across diverse use cases.

The key difference: major upgrades are planned events, not surprises that slip through loose version constraints.

What Happens When Child Modules Conflict?

What happens when your Root Module needs two Child Modules with incompatible version requirements? Consider this scenario:

  • Child Module A requires aws >= 5.60.0 (needs a newer feature)
  • Child Module B requires aws <= 5.59.0 (hasn’t been updated for newer versions)

This creates an impossible constraint: no AWS provider version can satisfy both >= 5.60.0 AND <= 5.59.0. TF will fail with a version constraint error, and your project is stuck.

The recommended resolution strategy is to update the restrictive module. Contact the maintainer of Module B, requesting for them to update the constraints, or consider contributing code changes to remove the upper bound restriction.

If it’s not viable to update the conflicting child module, you might consider finding alternative modules or forking them into your own repository. This is easier said than done. Replacing a child module can quickly become a headache, either due to state management challenges or the ongoing maintenance burden.

Using Terraform Lock Files

Terraform lock files (.terraform.lock.hcl) pin exact provider versions and include cryptographic checksums for each platform you use. This helps you get consistent installs across environments and ensures the provider binaries haven’t been tampered with, as they match the originals signed by the authors.

Terraform generates the lock file during init execution by resolving the entire provider dependency graph. Since the Root Module is the entry point, it controls the full execution context, and therefore, it’s the only place where the full set of providers is known. Terraform lock files are only for Root Modules.

If your team works across different platforms, such as macOS, Windows, and Linux-based automation runners, it’s a good idea to pre-fill the checksums for all of them. You can do this with the tofu providers lock/terraform providers lock command, specifying each platform you plan to support:

tofu providers lock -platform=darwin_amd64 -platform=linux_amd64

With this command, the TF CLI will:

  1. Resolve dependencies for all required providers in your configuration.
  2. Download provider plugins and calculate SHA-256 hashes for each plugin binary on each specified platform.
  3. Update (or create) the lock file with entries listing each provider version and its checksum per platform.

Lock files belong to the Root Modules, where they serve as the single source of truth for managing TF provider dependencies. Be sure to commit them to your repository. This has the added benefit of enabling downstream processes to do Checksum Verification before using the downloaded providers.

Pinning Terraform for Your Organization with Aqua

We recommend using tools like Aqua (asdf is another) to centrally manage and pin versions for the TF CLI and other CLI tools for your entire repository. Aqua is an open-source Golang-based project that simplifies version management and ensures everyone consistently uses the appropriate versions of tooling across your projects.

We at Masterpoint have adopted Aqua internally with great success (e.g. terraform-module-template) and recommend it to our clients. Aqua has community-contributed packages for IaC, Python, Node, and many other categories.

# aqua.yaml
# aqua - Declarative CLI Version Manager
# https://aquaproj.github.io/
registries:
  - type: standard
    ref: v4.354.0 # renovate: depName=aquaproj/aqua-registry
packages:
  - name: terraform-docs/terraform-docs@v0.20.0
  - name: opentofu/opentofu@v1.9.1
  - name: getsops/sops@v3.10.1
  - name: spacelift-io/spacectl@v1.11.0
  - name: open-policy-agent/opa@v1.0.0

In this snippet:

  • Registries: Tell Aqua where to look for package metadata. Here, it uses the “standard” registry at version 4.354.0.
  • Packages: Lists the tools you want, in the form owner/tool@version. For example, opentofu/opentofu@v1.9.1.

When you run aqua install, it reads this file, downloads each listed CLI at the specified version, verifies its integrity, and makes it available in your PATH, ensuring a repeatable and consistent environment across machines, operating systems, and in automated pipelines.

Can I Automate Version Upgrades?

There are various tools to help you automate TF CLI, module, and provider version updates in a predictable and consistent way. We recommend adopting Renovate —- a GitHub Action that runs on a set schedule, and helps to handle the grunt work – scanning your code, detecting outdated dependencies, and opening pull requests with version bumps and updated lock files. You get notified, review the changes, and merge when you’re ready.

Here are some basic configuration examples to get you started.

Renovate Configs

Add a renovate.json at your repo root:

{
  "extends": ["config:base"],
  "managers": ["terraform"],
  "packageRules": [
    {
      "matchDatasources": ["terraform-provider", "terraform-module"],
      "groupName": "tfdeps",
      "schedule": ["weekly"]
    }
  ]
}

With this config, Renovate will scan both providers and modules versions, bumping them in a single weekly PR.

What do these PRs actually look like?

When you update regularly, the PRs are typically small and easy to review. For example, this Renovate PR shows a major bump from cloudposse/iam-policy/aws v1.0.1 to v2.0.2 – straightforward to review and test.

Renovate PR example showing module updateRenovate PR diff showing clean changes

Update Frequency Varies

Modules and providers don’t all release weekly – some may go months between updates, while others release frequently. Check the release notes and community feedback to define a schedule that works best for your project.

Confidence Through Testing

If you have solid Terraform tests configured and executed on PRs, you can configure Renovate to automerge minor provider or module updates automatically, further reducing maintenance overhead.

Check out our blog post on how to leverage AI for writing the tests.

Important Note

Currently, Renovate’s TF manager does not automatically update the .terraform.lock.hcl file in the Root Module. This limitation is being tracked as an open issue Terraform/OpenTofu lock files are not updated with Child Module constraints #29944. As a workaround, we suggest leveraging Renovate’s postUpgradeTasks feature to regenerate and commit your lock file immediately after a dependency upgrade. Without this workaround, your lock file drifts out of date even after providers or modules are bumped.

{
  "extends": ["config:base"],
  "managers": ["terraform"],
  "packageRules": [
    {
      "matchDatasources": ["terraform-provider", "terraform-module"],
      "postUpgradeTasks": [
        {
          "commands": [
            "tofu providers lock -platform=linux_amd64 -platform=darwin_amd64"
          ],
          "fileFilters": [".terraform.lock.hcl"]
        }
      ],
      "groupName": "tf deps",
      "schedule": ["weekly"]
    }
  ]
}

Check out our config in the open-source repository, which includes more managers and rules – renovate.json.

Conclusion

Having an intentional strategy around TF versioning issues is important to keep any team’s infrastructure footprint (however small or larger) running smoothly for the long-term. The approaches we surveyed in this article, pinning versions well, upgrading with intent, and keeping things consistent across the board, provide a solid basis for averting common pitfalls. Our hope is that these strategies serve to make your infrastructure predictable and boring, in the best way possible.

If you update regularly, you save yourself the hassle of manual checks. And you make sure your platform stays secure and supported.

It’s an investment that pays off every single day ✨

Get a standardized, predictable, and efficient infrastructure management process

Skip the stress and let us organize the mess. Reach out today for a free assessment.

Schedule Your Free Assessment →