DevOps & Platform

Migrate a Terraform Project to OpenTofu (Safely, with State and CI)

Intermediate45 min to complete12 min readJuly 2, 2026

Quick answer

OpenTofu forked from Terraform 1.5.x and is state-compatible, so migrating is mostly a binary swap — but 'mostly' is where projects get burned. This tutorial walks the safe path: back up state, install tofu, gate on a no-change plan, fix provider registry and lock-file differences, and cut CI over deliberately.

intermediate · 45 min

Before you begin

  • An existing Terraform project (>= 1.5) with a remote backend
  • terraform and git installed and working
  • Ability to install the tofu CLI on your machine and CI runners
  • A non-prod workspace or environment you can migrate and test first
OpenTofu
Terraform
Infrastructure as Code
Migration
State Management
CI/CD
DevOps

OpenTofu forked from Terraform at version 1.5.x, back when Terraform's license changed from MPL to the BUSL. Because it's a fork of the same codebase, OpenTofu speaks the same HCL, reads the same state format, and understands the same backend configuration. That's what makes migrating "mostly a binary swap" — you point the tofu CLI at the project you already have, and it picks up your backend and state as-is. If you're still deciding whether the switch is worth it, read OpenTofu vs Terraform or our toolkit comparison first; this tutorial assumes you've decided to move.

The catch is that "mostly" hides the parts that actually bite: OpenTofu resolves providers from registry.opentofu.org instead of registry.terraform.io, so your .terraform.lock.hcl needs regenerating; any Terraform-1.6+ features you adopted after the fork may not exist yet; and HCP Terraform-only features (Sentinel, Stacks) simply don't run on OpenTofu. This is a controlled cutover, not a sed on your CI file.

The whole strategy hinges on one gate: after swapping binaries, tofu plan must report no changes. If it does, your state and config are compatible and you can proceed. If it doesn't, you stop and investigate before anything touches real infrastructure. We'll do the entire thing on a non-prod workspace first.

What You'll Build

  • A verified backup of your remote state before any tool touches it
  • OpenTofu installed alongside Terraform, both usable side by side
  • A no-op tofu plan on a non-prod workspace — the safety gate that proves compatibility
  • A regenerated .terraform.lock.hcl with providers sourced from registry.opentofu.org
  • A CI pipeline calling tofu instead of terraform, cut over deliberately
  • A documented rollback path back to Terraform

Step 1: Pin Your Versions and Back Up State

Before anything, record exactly what you're running so you can reproduce or revert. State is the one thing you cannot regenerate — treat the backup as non-negotiable.

bash
terraform version                 # note the exact version, e.g. 1.5.7 or 1.9.x
terraform providers               # list provider sources and versions in use

# Pull a local copy of the remote state as a backup
terraform state pull > state-backup-$(date +%Y%m%d-%H%M%S).tfstate

Store that backup somewhere outside the working directory (an S3 prefix, a secure bucket, wherever your team keeps break-glass artifacts). If your backend supports versioning — S3 versioning, Terraform Cloud state history — confirm it's on. That's your real safety net.

Also confirm the state isn't locked before you start; a stale lock will block both terraform and tofu. If you hit lock errors, see fixing the Terraform state lock error.

Step 2: Install the OpenTofu CLI

Install tofu without removing terraform — you want both available so you can compare plans and roll back instantly.

bash
1# macOS (Homebrew)
2brew install opentofu
3
4# Linux (standalone installer script)
5curl -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
6chmod +x install-opentofu.sh
7./install-opentofu.sh --install-method standalone
8rm install-opentofu.sh
9
10# Verify
11tofu version

tofu version should print an OpenTofu version (1.6 or newer). Keep both binaries on your PATH; the commands are identical (tofu init, tofu plan, tofu apply), which is exactly why the swap is low-friction.

Step 3: Select a Non-Prod Workspace

Never migrate production first. Pick a dev or staging workspace and confirm you're on it before running anything.

bash
terraform workspace list          # see available workspaces
terraform workspace select dev    # or use your non-prod backend/var setup

If you separate environments by directory or backend config rather than workspaces, cd into the non-prod one instead. The point is the same: prove the migration on something you can break without paging anyone.

Step 4: Initialize with OpenTofu

Run tofu init in the project. OpenTofu reads your existing backend configuration and connects to the same remote state Terraform was using — no state migration, no re-import.

bash
tofu init

Under the hood it re-runs backend initialization and downloads providers. Because OpenTofu pulls from registry.opentofu.org, it fetches the OpenTofu-hosted mirror of your providers. For the mainstream providers (AWS, Google, Azure, Kubernetes, Helm) these mirror the same upstream binaries, so this normally just works. If a provider fails to resolve here, jump to Step 6 before going further.

Step 5: The Safety Gate — Confirm a No-Op Plan

This is the single most important step. Run a plan and demand that OpenTofu reports no changes.

bash
tofu plan

You want to see:

No changes. Your infrastructure matches the configuration.

That output means OpenTofu read your state, evaluated your config, and found the live infrastructure already matches — i.e. the migration introduced zero drift. Do not apply anything if the plan shows changes. A non-empty plan after only swapping binaries signals a real incompatibility (a provider version difference, a post-1.5 language feature, or a lock-file mismatch), and applying it could modify or destroy resources. Investigate first; the Common Issues section below covers the usual culprits.

If it's clean, you've proven compatibility on this workspace.

Step 6: Regenerate the Lock File for OpenTofu's Registry

Your .terraform.lock.hcl records provider checksums keyed to registry.terraform.io. After migrating you want it to reflect registry.opentofu.org sources and OpenTofu-verified checksums. Regenerate it:

bash
tofu init -upgrade

If you build and apply across multiple OSes (local macOS, Linux CI), record hashes for every platform so CI doesn't fail on a missing checksum:

bash
tofu providers lock \
  -platform=linux_amd64 \
  -platform=darwin_arm64 \
  -platform=darwin_amd64

Commit the updated .terraform.lock.hcl. Then re-run tofu plan one more time to confirm the refreshed lock file still yields No changes. Only after this second clean plan should you consider the workspace migrated.

Step 7: Cut Over CI

With the CLI proven locally, update your pipeline to call tofu. The commands map one-to-one, so this is mostly a rename plus installing the binary on the runner.

bash
1# Example GitHub Actions step
2# - name: Setup OpenTofu
3#   uses: opentofu/setup-opentofu@v1
4#   with:
5#     tofu_version: "1.9.0"
6#
7# - run: tofu init -input=false
8# - run: tofu plan -input=false -out=tfplan
9# - run: tofu apply -input=false tfplan

If your CI wraps commands in a Makefile or shell script, change the binary name there and keep the flags identical:

bash
# Before
terraform init  && terraform plan  -out=tfplan

# After
tofu init       && tofu plan       -out=tfplan

Roll this out per environment in the same order you validated: dev first, watch a full plan/apply cycle succeed, then promote to staging and finally production. Keep the terraform binary installed on runners until every environment is confirmed — that's your instant rollback lever.

Step 8: Know What You're Leaving Behind

OpenTofu is a fork, not a mirror. Some Terraform features are HCP Terraform / Terraform Enterprise-only and do not run on OpenTofu:

  • Sentinel policy-as-code — does not run on OpenTofu. Migrate policy enforcement to Open Policy Agent (OPA/Conftest) or OpenTofu-compatible tooling.
  • Terraform Stacks and other HCP Terraform-exclusive workflow features.
  • Any provider or module that resolves only from registry.terraform.io with no OpenTofu registry mirror — rare, but check niche/private providers.

Also flag any config that relies on Terraform features added after the 1.5 fork point. OpenTofu has since shipped its own features (some overlapping, some unique), so a given 1.6+ Terraform construct may be present, absent, or named differently. Grep your code for newer functions and blocks and confirm each against the OpenTofu docs before you rely on it.

Step 9 (Optional): Adopt OpenTofu-Only Features

Once you're fully on OpenTofu, you can use things Terraform doesn't offer — most notably client-side state encryption, which encrypts state and plan files at rest without depending on backend-level encryption:

hcl
1terraform {
2  encryption {
3    key_provider "pbkdf2" "mykey" {
4      passphrase = var.state_passphrase   # supply via TF_VAR or a secrets manager
5    }
6    method "aes_gcm" "encrypt" {
7      keys = key_provider.pbkdf2.mykey
8    }
9    state {
10      method = method.aes_gcm.encrypt
11    }
12    plan {
13      method = method.aes_gcm.encrypt
14    }
15  }
16}

Adopt these deliberately and last. The moment you write encrypted state or use an OpenTofu-only construct, you've crossed a one-way door: rolling back to Terraform is no longer a clean binary swap (see Tear Down).

Common Issues

  • Provider fails to resolve on tofu init — a provider in your config has no mirror on registry.opentofu.org, or its source address is pinned to registry.terraform.io. Set an explicit source in required_providers and re-run tofu init -upgrade. For private providers, point OpenTofu at your own registry/mirror.
  • Lock-file checksum mismatch in CI — your .terraform.lock.hcl lacks hashes for the CI runner's platform. Run tofu providers lock with every -platform you build on and commit the result.
  • tofu plan shows unexpected changes — usually a provider version difference between what Terraform resolved and what OpenTofu resolved. Compare terraform providers vs tofu providers output and pin the version in required_providers so both tools agree.
  • Version-constraint or unknown-function errors — you're using a Terraform 1.6+ feature that doesn't exist (or differs) in your OpenTofu version. Check the OpenTofu docs for the equivalent, or upgrade OpenTofu to a version that supports it.
  • Stale state lock blocks init/plan — a previous run left the lock held. Confirm no run is active, then release it (force-unlock as a last resort). Details in the state lock fix guide.

Frequently Asked Questions

Do I need to migrate or convert my state file?

No. OpenTofu forked from Terraform 1.5.x and reads the same state format from the same backends, so tofu init connects to your existing remote state with no conversion. This is the core reason the migration is low-risk — your state is the compatibility anchor, which is exactly why you back it up but don't rewrite it.

Can I roll back to Terraform after switching to OpenTofu?

Yes, as long as you haven't adopted OpenTofu-only features. State written by OpenTofu is compatible back to the 1.5.x fork point, so reverting is another binary swap plus a terraform init. The exception: if you enabled state encryption or used constructs Terraform doesn't understand, Terraform can't read that state — so treat those features as a one-way door.

Why does tofu plan show changes right after migrating?

The most common cause is that OpenTofu resolved a different provider version than Terraform did, producing a diff in computed attributes. Compare provider versions between the two tools and pin them in required_providers. Other causes are a post-1.5 Terraform feature OpenTofu handles differently, or a lock file that still references registry.terraform.io. Never apply a surprise plan — investigate first.

Will Sentinel policies keep working on OpenTofu?

No. Sentinel is an HCP Terraform / Terraform Enterprise feature and does not run on OpenTofu. If you rely on Sentinel for policy-as-code, plan to move that enforcement to Open Policy Agent (OPA), Conftest, or another OpenTofu-compatible policy engine as part of the migration, not after.

Do I have to update every provider source address in my code?

Usually not. OpenTofu maps well-known provider sources to its own registry automatically, so mainstream providers resolve without edits. You only need explicit source addresses in required_providers for providers that don't have an OpenTofu mirror or that you host privately — and pinning source and version explicitly is good practice regardless.

Tear Down

Rolling back is the reverse binary swap, valid only if you avoided OpenTofu-only features:

bash
1# Point Terraform back at the same project and state
2terraform init
3
4# Confirm a no-op plan under Terraform, exactly as you did for OpenTofu
5terraform plan     # expect: No changes.
6
7# Restore CI to call terraform instead of tofu

If you did adopt OpenTofu-only features (state encryption, Stacks, unique functions), you cannot cleanly roll back — Terraform won't read that state. In that case, restore the pre-migration state backup from Step 1 into a Terraform-compatible backend and reconcile drift manually. This is why you keep the backup and delay OpenTofu-only features until the migration is proven and committed.

Official References

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.