Terraform to OpenTofu: Test State Before Switching
Last edited on August 9, 2026

Replacing the terraform executable with tofu is easy. Proving that the new engine can read the same configuration, providers and state without proposing an unexpected change is the migration. Do not let the first OpenTofu apply become the compatibility test. Build a receipt from a no-change plan, a controlled canary and a rehearsed return to the original engine first.

This guide is for teams that already manage infrastructure with Terraform and are evaluating OpenTofu. It uses a disposable local-state project with the built-in terraform_data resource, so the reproduced path does not contact a cloud account or download a provider. Production projects still need separate tests for every provider, module, backend, policy and CI runner they use.

Define the Decision Before Running Either Engine

OpenTofu’s official migration guide recommends backing up code and state, installing OpenTofu, running tofu init, verifying the plan and trying a small change. That is the right sequence, but each step needs a pass condition. A successful init proves dependency setup, not semantic agreement. A readable state file proves parsing, not that the next plan is empty.

Use three independent questions:

  1. Can OpenTofu initialize with the same configuration and dependency selections?
  2. Does tofu plan -detailed-exitcode return 0 against the untouched Terraform state?
  3. After a controlled OpenTofu change, can the agreed rollback method return the original engine to a verified no-change state?

The OpenTofu state documentation warns against direct state JSON editing and explains that state formats evolve. Treat state as an engine-managed artifact. For remote backends, protect locking and version history; never replace a shared object by copying a local file over it while another run could write.

Establish a Guarded Compatibility Lab

The following sequence was reproduced on Debian 13.6 on August 9, 2026 UTC with Terraform 1.5.7 and OpenTofu 1.12.5. HashiCorp changed Terraform licensing after 1.5.x, so this version pair is a compatibility probe, not a recommendation to freeze a production estate indefinitely.

Download both pinned binaries into one mktemp directory and create a resource that needs no external provider. The owner field stays constant; label will be the controlled canary.

set -euo pipefail
TOFU_LAB=$(mktemp -d /tmp/voxfor-tofu.XXXXXX)
cd "$TOFU_LAB"

curl -fsSLO https://releases.hashicorp.com/terraform/1.5.7/terraform_1.5.7_linux_amd64.zip
curl -fsSLO https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12.5_linux_amd64.zip
unzip -q terraform_1.5.7_linux_amd64.zip -d terraform-bin
unzip -q tofu_1.12.5_linux_amd64.zip -d tofu-bin
TF="$TOFU_LAB/terraform-bin/terraform"
TOFU="$TOFU_LAB/tofu-bin/tofu"

cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.5.0"
}

variable "label" {
  type    = string
  default = "terraform-baseline"
}

resource "terraform_data" "migration_receipt" {
  input = {
    label = var.label
    owner = "voxfor-lab"
  }
}

output "receipt" {
  value = terraform_data.migration_receipt.output
}
EOF

"$TF" version | sed -n '1p'
"$TOFU" version | sed -n '1p'
sha256sum main.tf

Pinning is not cosmetic. Record the exact Terraform, OpenTofu, provider and module versions that created the receipt. A real repository should also preserve its lock file and verify that the intended registry sources and checksums remain acceptable. Firefly’s Terraform-to-OpenTofu migration overview correctly highlights provider, module and backend checks, but “compatible” still has to be proved against your dependency graph.

Create the Terraform Baseline and Preserve It

Initialize and apply only the disposable fixture with Terraform. Pull state through the CLI, checksum the configuration and state copy, and record both the resource address and semantic output. The state backup is useful only when it is tied to recognizable infrastructure intent.

"$TF" init -input=false
"$TF" apply -auto-approve -input=false
"$TF" state pull > state-before.json

test -s state-before.json
sha256sum main.tf state-before.json
"$TF" state list
"$TF" output -json receipt | jq -c .

In production, capture backend identity, workspace, state serial and lineage, not just a filename. Version the state in the backend or export it through the supported CLI, then keep the recovery copy in a separately controlled location. The principle is similar to object-locked backup generations: a recoverable history needs a retention and credential boundary, not merely another file beside the live object.

Require an OpenTofu No-Change Plan

Now initialize OpenTofu in the unchanged project and use -detailed-exitcode. Exit 0 means no proposed changes, 2 means a nonempty plan, and any other nonzero value is an execution failure. Do not hide those states behind || true or a CI step that treats both 0 and 2 as success.

"$TOFU" init -input=false

set +e
"$TOFU" plan -input=false -detailed-exitcode -out=zero.tfplan
ZERO_STATUS=$?
set -e

test "$ZERO_STATUS" -eq 0
"$TOFU" state list
"$TOFU" output -json receipt | jq -c .
printf 'tofu_zero_plan_exit=%s\n' "$ZERO_STATUS"

Reject the migration candidate if this returns 2 until every proposed action has an explicit explanation. Provider schema upgrades, changed defaults, unavailable registry sources, backend authentication and module behavior are common investigation boundaries. The Palark OpenTofu migration account emphasizes matching current infrastructure before change; this gate turns that idea into a machine-checkable decision.

On a mature repository, run this test in an isolated branch and against a cloned or read-only backend when the backend permits it. Never let two engines acquire or bypass locks on the same production workspace concurrently. If CI performs plans, update a disposable runner first; the self-hosted GitHub Actions runner guide provides a useful boundary for labels, tokens, service isolation and runner acceptance.

Prove a Controlled OpenTofu Change

A zero plan proves the read path. It does not prove that OpenTofu can create a reviewable plan and persist the intended change. Change only the fixture label, require exit 2, save the plan, apply that exact file and inspect the output.

set +e
"$TOFU" plan -input=false -detailed-exitcode \
  -var='label=opentofu-canary' -out=canary.tfplan
CANARY_STATUS=$?
set -e

test "$CANARY_STATUS" -eq 2
"$TOFU" apply -input=false canary.tfplan
"$TOFU" output -json receipt | jq -c .
printf 'tofu_canary_plan_exit=%s\n' "$CANARY_STATUS"

For real infrastructure, choose a reversible, low-blast-radius object whose behavior can be checked outside the state engine: a harmless label, tag or test resource in a nonproduction workspace. Avoid a canary that restarts a database, rotates a credential or changes network reachability. OneUptime’s existing-state migration guide also recommends planning before apply and keeping provider versions aligned; the extra requirement here is a saved plan plus a separate application receipt.

The reproduced run returned these representative receipts. The configuration and state hashes are shortened here; your values will differ.

Terraform v1.5.7
OpenTofu v1.12.5
main.tf sha256=c3d59...
state-before.json sha256=4a13...
TERRAFORM_BASELINE {"label":"terraform-baseline","owner":"voxfor-lab"}
TOFU_ZERO_PLAN exit=0 summary=No changes. Your infrastructure matches the configuration.
STATE_LIST terraform_data.migration_receipt
TOFU_CANARY_PLAN exit=2 summary=Plan: 0 to add, 1 to change, 0 to destroy.
TOFU_CANARY {"label":"opentofu-canary","owner":"voxfor-lab"}
TERRAFORM_ROLLBACK_PLAN exit=0 summary=No changes. Your infrastructure matches the configuration.

Rehearse the Return Path Before Cutover

Rollback is not “run the old binary and hope.” Decide whether the failed migration will be abandoned before any state write, reverted through normal configuration, or recovered from a backend version. The correct route depends on what wrote state and what changed outside it.

In this local-only fixture, restore the saved state file, return the configuration variable to its default and require Terraform to report no changes. This deliberately demonstrates the mechanics on an isolated file; it is not permission to overwrite remote production state.

cp state-before.json terraform.tfstate

set +e
"$TF" plan -input=false -detailed-exitcode -out=rollback.tfplan
ROLLBACK_STATUS=$?
set -e

test "$ROLLBACK_STATUS" -eq 0
"$TF" output -json receipt | jq -c .
printf 'terraform_rollback_plan_exit=%s\n' "$ROLLBACK_STATUS"

With an S3-compatible or managed backend, use its documented object-version recovery or supported state command while the workspace is locked and all automation is paused. Record the previous version identifier and state serial. Env0’s step-by-step OpenTofu migration guide covers backends, locking, CI and rollback broadly; be more conservative than any generic instruction that removes caches or lock files without first explaining dependency and concurrency consequences.

The migration rehearsal passes only when both pinned binaries are identified; the Terraform baseline has a nonempty exported state plus recognizable output; OpenTofu initializes without changing dependency intent; the untouched OpenTofu plan exits 0; state addresses and semantic output match; the saved canary plan exits 2 with exactly one expected in-place change and applies successfully; the original state is recoverable through the scoped lab method; and Terraform then exits 0 with the original receipt. Any unexplained create, destroy, replace, provider change, backend mismatch, lock bypass or output drift is a failed gate.

Convert the Lab Into a Production Migration Contract

Inventory every workspace, backend, provider source, module source, policy check and automation entry point. Run an unchanged OpenTofu plan for each workspace and store the exit code, plan summary and dependency lock fingerprint. High-risk workspaces deserve a clone with representative credentials and data boundaries, not a first test against production.

Then migrate one nonproduction workspace through the full route: plan, human review, low-risk canary, external acceptance, subsequent zero plan and original-engine return rehearsal. A plan engine cannot prove that an endpoint became reachable or an application stayed healthy. The cloud-init readiness investigation demonstrates the same boundary at boot: a provisioning tool can finish before the workload is actually ready. Pair infrastructure output with the same acceptance checks used after automated deployment; the VPS CI/CD rollback workflow shows why deployment, service health and rollback need distinct receipts.

Coordinate the cutover. Freeze Terraform writes, update CI images and local tooling, decide who owns backend recovery, and prevent mixed-engine concurrency. Keep the compatibility evidence with the change record. After the first production apply, run another no-change plan and application acceptance before declaring the workspace migrated.

The inspected Voxfor service pages describe VPS and hosting capacity, but they do not specifically promise OpenTofu migration engineering or Terraform state recovery. No service link is included because the reader’s immediate decision is tool and state compatibility, not server-plan selection.

Remove Only the Disposable Lab

Cleanup must refuse an empty or unexpected directory. In production, cleanup means removing candidate CI images and temporary credentials only after the accepted engine, state history and rollback evidence are retained.

case ${TOFU_LAB:-} in
  /tmp/voxfor-tofu.*)
    test -x "$TF"
    test -x "$TOFU"
    test -s "$TOFU_LAB/state-before.json"
    cd /
    rm -rf --one-file-system "$TOFU_LAB"
    test ! -e "$TOFU_LAB"
    ;;
  *) printf 'Refusing cleanup: unexpected TOFU_LAB\n' >&2; exit 1 ;;
esac
unset TOFU_LAB TF TOFU ZERO_STATUS CANARY_STATUS ROLLBACK_STATUS

FAQ: Terraform to OpenTofu State Migration

Can OpenTofu read existing Terraform state?

OpenTofu is designed for migration from Terraform and documents compatibility, but compatibility is version-, provider-, backend- and configuration-dependent. Back up state and code, initialize in a controlled environment, and require a no-change OpenTofu plan for every workspace before allowing a write.

Does a successful tofu init prove the migration is safe?

No. It proves initialization completed. It does not prove that provider behavior, state interpretation or planned infrastructure actions match Terraform. The next gate is tofu plan -detailed-exitcode, followed by inspection of every proposed action.

What should the detailed exit code be before migration?

Against unchanged configuration and state, it should be 0. Exit 2 means the plan contains changes and requires investigation; other nonzero statuses mean planning failed. CI must preserve this distinction instead of treating every nonzero result identically.

Should Terraform and OpenTofu share a production backend during testing?

Do not run them concurrently against a writable production workspace. Pause automation and respect backend locking. Prefer a cloned or read-only test path where possible, then schedule a controlled cutover with one designated engine and a documented state-version return path.

Do I need to delete the Terraform lock file?

Not as a default migration step. The lock file records dependency selections and checksums that help make plans reproducible. Inspect source and checksum compatibility deliberately; deleting it can silently choose different provider builds and create a second variable during the engine test.

How do I roll back from OpenTofu to Terraform?

The safe method depends on whether OpenTofu wrote state and changed infrastructure. Before cutover, preserve a backend version or supported state export, record serial and lineage, freeze other writers and rehearse recovery. After any real apply, also revert the infrastructure change through a reviewed plan when needed; restoring state alone does not undo external resources.

Make the Zero Plan a Release Artifact

A credible migration record contains more than a successful command: pinned engine and dependency identities, an untouched no-change plan, a bounded canary with external acceptance, and a rehearsed state return path. Those receipts let a team decide whether to proceed, pause or recover without using production as the experiment.

Start with one disposable project, then repeat the contract across every real workspace. Protect state approval as deliberately as an approval-gated file-integrity baseline: the newest artifact is not automatically the trusted one. OpenTofu adoption is ready only when unexplained drift is zero, automation has one owner, and rollback is an executed procedure rather than a paragraph in the change ticket.

Leave a Reply

Your email address will not be published. Required fields are marked *