Who this migration is for
Teams usually move a Terraform estate to OpenTofu because of licensing policy, procurement rules, or a preference for an open-source infrastructure-as-code toolchain. The state model stays familiar, so the real work is replacing the CLI, updating wrappers and CI, and proving that every workspace still produces the same plan.
As of August 15, 2026, this guide assumes an existing Terraform 1.5.x workflow, with examples captured against Terraform CLI 1.5.7 and OpenTofu 1.6.2. If your state was last written by a newer Terraform release, test the migration against a copied backend before touching production state.
When this migration is worth doing
This migration is usually worth doing when your team already runs Terraform through Git, CI, and remote state, and you want the OpenTofu CLI without changing every module at once. It is a good fit for S3, GCS, AzureRM, Consul, or HTTP-backed state, especially when provider versions are pinned and applies are already serialized.
It is also a good fit when you can make the move in small units: one repo, one backend, and one workspace at a time. A typical safe rollout is development first, staging second, and production last, with a 30 to 60 minute apply freeze per workspace.
The migration is less attractive if your workflow depends heavily on Terraform Cloud remote execution, Sentinel policies, private module registry behavior, or HashiCorp-specific governance features. In that case, treat this as a platform migration, not a CLI swap. Move state and policy controls deliberately before changing apply ownership.
Do not combine the OpenTofu migration with provider upgrades, module rewrites, backend changes, or large refactors. A clean migration should answer one question: does the same configuration produce the same plan under OpenTofu?
Prerequisites and pre-migration checklist
Before you run OpenTofu against shared state, make sure the current Terraform workflow is boring and repeatable.
- Terraform version recorded: Capture the exact Terraform CLI version used by developers and CI. For this guide, the baseline is Terraform 1.5.7.
- OpenTofu version selected: Pin a specific OpenTofu version in CI. The examples use OpenTofu 1.6.2. Do not use a floating latest version.
- No pending drift: Run a Terraform plan before migration. A zero-change plan is the safest starting point.
- Remote state backed up: Pull a state snapshot for every workspace. If you use S3, bucket versioning should already be enabled.
- Locking works: Confirm that your backend lock works before the migration. For S3, a DynamoDB lock table is still common.
- Provider versions are pinned: Commit the existing
.terraform.lock.hclfile. Do not run provider upgrades during the migration. - Apply ownership is clear: During each workspace cutover, allow only one apply path. Disable old Terraform apply jobs before enabling OpenTofu apply jobs.
- Credentials are identical: OpenTofu should run with the same AWS, GCP, Azure, Git, registry, and secret access that Terraform used.
Step-by-step migration path
1. Pick a pilot repo and freeze applies
Start with a small but real workspace. A good pilot has fewer than 100 managed resources, one remote backend, and no urgent production changes in progress.
git checkout main
git pull --ff-only
git checkout -b migrate-to-opentofu
terraform version
terraform workspace list
terraform providers
terraform state list | wc -l
Record this output in the pull request or migration ticket. It gives you a simple before-and-after comparison if you need to debug provider, state, or workspace behavior later.
2. Back up state for every workspace
Pull a state snapshot before running OpenTofu. This command reads the current state through the configured backend and writes a local backup file.
mkdir -p state-backups
terraform workspace select dev
terraform state pull > state-backups/dev-$(date -u +%Y%m%dT%H%M%SZ).tfstate
terraform workspace select staging
terraform state pull > state-backups/staging-$(date -u +%Y%m%dT%H%M%SZ).tfstate
terraform workspace select prod
terraform state pull > state-backups/prod-$(date -u +%Y%m%dT%H%M%SZ).tfstate
sha256sum state-backups/*.tfstate
Store the backups somewhere access-controlled, such as a private incident bucket or your password manager鈥檚 secure file storage. Do not commit state files to Git.
3. Confirm Terraform has a clean plan
Run Terraform one last time before changing the toolchain. If this returns exit code 2, you already have drift or pending changes. Fix that first.
terraform workspace select dev
terraform init -input=false -lockfile=readonly
terraform plan -input=false -lock-timeout=5m -detailed-exitcode
The expected exit codes are 0 for no changes, 1 for an error, and 2 for a non-empty plan. For the lowest-risk migration, proceed only when Terraform returns 0 in the pilot workspace.
4. Install OpenTofu and initialize without changing providers
Install OpenTofu locally and in CI using the same pinned version. Then remove the local Terraform working directory so OpenTofu initializes from the committed configuration and lock file.
tofu version
rm -rf .terraform
tofu init -input=false -reconfigure -lockfile=readonly
The -lockfile=readonly flag prevents OpenTofu from silently changing provider selections during the first run. If initialization fails because the lock file lacks checksums for a CI platform, add the missing platform checksums in a separate commit.
tofu providers lock \
-platform=linux_amd64 \
-platform=darwin_arm64
5. Update version constraints carefully
Many Terraform repos pin the CLI tightly. If your root module requires Terraform 1.5.x exactly, OpenTofu 1.6.2 may fail the version check. Update only the CLI constraint, and leave provider constraints unchanged.
terraform {
required_version = ">= 1.6.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
Keep the provider source addresses as they are unless you have a specific reason to change them. Provider migration is a separate risk and should not be mixed into this cutover.
6. Compare Terraform and OpenTofu plans
If Terraform produced a zero-change plan, OpenTofu should also produce a zero-change plan in the same workspace.
tofu workspace select dev
tofu plan -input=false -lock-timeout=5m -detailed-exitcode
If you must migrate while a known change is pending, save both plans and compare the resource actions. The binary plan files are tool-specific, so compare their JSON summaries instead.
terraform plan -input=false -lock-timeout=5m -out=/tmp/terraform.tfplan
terraform show -json /tmp/terraform.tfplan \
| jq -r '.resource_changes[] | [.address, (.change.actions | join(","))] | @tsv' \
> /tmp/terraform-actions.tsv
tofu plan -input=false -lock-timeout=5m -out=/tmp/tofu.tfplan
tofu show -json /tmp/tofu.tfplan \
| jq -r '.resource_changes[] | [.address, (.change.actions | join(","))] | @tsv' \
> /tmp/tofu-actions.tsv
diff -u /tmp/terraform-actions.tsv /tmp/tofu-actions.tsv
The action list should match. If Terraform plans an update to aws_iam_role.app and OpenTofu plans a replacement of the same role, stop and inspect the provider version, provider lock file, and state.
7. Replace Terraform commands in scripts and wrappers
Search for hard-coded Terraform calls in CI, Makefiles, shell scripts, Dockerfiles, and documentation.
grep -R "terraform " -n \
.github \
.gitlab-ci.yml \
Makefile \
scripts \
Dockerfile \
2>/dev/null
A small Makefile change is usually enough for local workflows.
TF ?= tofu
init:
$(TF) init -input=false
fmt:
$(TF) fmt -check -recursive
validate:
$(TF) validate
plan:
$(TF) plan -input=false -lock-timeout=5m
apply:
$(TF) apply -input=false -lock-timeout=5m
Keep the variable name generic, such as TF, so you can run make plan TF=terraform during rollback testing.
8. Update CI to run OpenTofu
Pin the OpenTofu version in CI and keep plan exit-code handling explicit. This GitHub Actions example treats exit code 2 as a successful plan with changes, while still failing on exit code 1.
name: tofu-plan
on:
pull_request:
paths:
- "infra/**"
jobs:
plan:
runs-on: ubuntu-22.04
defaults:
run:
working-directory: infra
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.6.2
- name: Init
run: tofu init -input=false -lockfile=readonly
- name: Format
run: tofu fmt -check -recursive
- name: Validate
run: tofu validate
- name: Plan
run: |
set +e
tofu plan -input=false -lock-timeout=5m -detailed-exitcode
code=$?
set -e
if [ "$code" -eq 1 ]; then
exit 1
fi
if [ "$code" -eq 2 ]; then
echo "OpenTofu plan completed with changes."
fi
Disable the old Terraform apply job before enabling an OpenTofu apply job. Two apply jobs pointing at the same backend create needless state-lock contention and can produce unsafe races if one path bypasses locking.
9. Handle Terraform Cloud separately
If your current workflow uses Terraform Cloud remote execution, do not point OpenTofu at the same workspace and start applying. First decide where execution, state, variables, and policy checks will live after the migration.
One common path is to move state out while Terraform still controls the workspace, then switch the CLI. For example, replace a Terraform Cloud configuration with an S3 backend, then run migration with Terraform before introducing OpenTofu.
terraform {
backend "s3" {
bucket = "company-tf-state-prod"
key = "network/prod.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks-prod"
encrypt = true
}
}
terraform init -migrate-state
terraform plan -input=false -lock-timeout=5m -detailed-exitcode
After state is in the new backend and Terraform shows the expected plan, continue with the OpenTofu steps. This keeps the backend migration and CLI migration separate.
10. Roll out workspace by workspace
Repeat the same sequence for each workspace. Use a fixed order and do not skip validation.
- Freeze applies for the workspace.
- Pull a fresh state backup.
- Run a Terraform plan and confirm the expected result.
- Run
tofu initwith the committed lock file. - Run an OpenTofu plan and compare results.
- Enable the OpenTofu CI job for that workspace.
- Disable the Terraform CI job for that workspace.
- Run the next real apply through OpenTofu only.
Common failure modes and recovery steps
The required version constraint rejects OpenTofu
If the root module says required_version = "~> 1.5.7", OpenTofu 1.6.2 may fail during init or validate. Change the CLI constraint to a range that matches your approved OpenTofu version, then run the plan again.
The provider lock file changes unexpectedly
If tofu init rewrites .terraform.lock.hcl, stop and inspect the diff. A checksum addition for a new platform is usually fine. A provider version change is not fine during the migration. Revert the provider version change and rerun init with -lockfile=readonly.
CI fails on exit code 2
tofu plan -detailed-exitcode returns 2 when the plan has changes. Many CI shells treat any non-zero code as failure. Handle code 2 explicitly, and reserve failure for code 1.
The backend lock is stuck
A failed job can leave a remote state lock behind. First confirm that no apply is still running. Then use the lock ID printed by the failed command.
tofu force-unlock LOCK_ID
Use force unlock only after you confirm that the original process is dead. Unlocking a live apply can corrupt state.
Plans differ because refresh found drift
If Terraform and OpenTofu plans were run minutes apart, a cloud-side change may appear as a tool difference. Run both plans again during a freeze, with the same credentials and provider lock file. If the difference remains, inspect the provider schema and state for the specific resource address.
Wrapper scripts still call Terraform
Old scripts are a common source of partial migration. Search for terraform in deploy scripts, pre-commit hooks, Docker images, and runbooks. A migration is not complete if production still applies through an old container image that has Terraform baked in.
Rollback plan
The rollback goal is to return to the previous Terraform apply path without overwriting valid infrastructure changes. Do not push an old state backup unless state is actually damaged.
- Stop all OpenTofu applies and disable the OpenTofu apply job.
- Revert the commit that changed CI, wrappers, and CLI constraints.
- Reinstall the previous Terraform CLI version, such as Terraform 1.5.7 if that was your recorded baseline.
- Run Terraform init against the same backend.
- Run a Terraform plan and inspect every action before applying.
git revert COMMIT_SHA
rm -rf .terraform
terraform version
terraform init -input=false -reconfigure -lockfile=readonly
terraform plan -input=false -lock-timeout=5m -detailed-exitcode
If OpenTofu already applied an approved infrastructure change, Terraform should normally read the current remote state and plan from there. Restoring yesterday鈥檚 state would discard the recorded change and can make later plans dangerous.
Use terraform state push only for state corruption recovery, and only after comparing the backup serial, lineage, and current remote state. For most failed migrations, reverting the toolchain is enough.
How to validate that the migration succeeded
A successful migration means OpenTofu owns plan and apply for each migrated workspace, state remains readable, provider versions did not change unexpectedly, and no unplanned resource actions appeared.
Run these checks for every workspace.
tofu workspace select prod
tofu init -input=false -lockfile=readonly
tofu fmt -check -recursive
tofu validate
tofu plan -input=false -lock-timeout=5m -detailed-exitcode
Pull the state after the first successful OpenTofu-controlled run and compare basic state shape with the pre-migration backup.
tofu state pull > state-backups/prod-after-opentofu.tfstate
jq '.serial, (.resources | length)' state-backups/prod-after-opentofu.tfstate
The exact serial number will depend on whether an apply occurred. The resource count should match unless the approved plan created or destroyed resources.
Use this done checklist before closing the migration:
- All migrated repos pin the same approved OpenTofu version in CI.
- Terraform apply jobs are disabled for migrated workspaces.
.terraform.lock.hclis committed and reviewed.- OpenTofu plans return 0 when no change is expected.
- Any exit code 2 plan is reviewed as a normal infrastructure change.
- State backups exist for every workspace migrated.
- Rollback has been tested in at least one non-production workspace.
- Runbooks, Makefiles, and onboarding docs use
tofucommands.
Closing notes
A Terraform to OpenTofu migration is usually straightforward when you keep provider versions, modules, and backends stable. The risky cases are mixed migrations, Terraform Cloud remote execution, private registries, and repos where nobody can say which job owns apply.
If you want senior infrastructure help on the move, MeteorOps can work inside your existing Slack, repos, and CI. Relevant examples include deploying identical development and production environments with Terraform and building scalable AWS infrastructure with Terraform, Kubernetes, and Airflow.




