CoursesOpenTofuMigrating from Terraform

Migrating from Terraform

Move an existing project, safely.

Intermediate14 min · lesson 5 of 12

Two mechanics, one logbook. That is the shape of a move from Terraform to OpenTofu. OpenTofu started life as a copy of Terraform 1.5.7, the last release published under an open licence (the Mozilla Public Licence, which lets anyone fork the code), so your .tf files and your state file mean exactly the same thing to the tofu binary as they did to the terraform binary. No resources move. Nothing gets rebuilt. The second mechanic picks up the same logbook and carries on from the same page.

So almost none of this work is rewriting code. Nearly all of it is proving nothing changed, and keeping a way back in case something did. One committed file genuinely changes during the swap: .terraform.lock.hcl, the record of which provider builds your team has agreed to trust. Your acceptance test is a single line of output, an empty first plan against your existing state. Everything below is about earning that line, checking what quietly shifted while you earned it, and being able to walk backwards afterwards.

Copy The Ledger Before You Touch Anything

Your .tf files live in git, so you can always get them back. State cannot be re-created. Think of it as a ledger in the accounting sense: one line per resource in your code, and against each line the real object that line owns. This bucket. This instance ID (the identifier the cloud handed back when it built the machine). Lose the ledger and the infrastructure is still sitting there, but nothing knows it owns the infrastructure, so the next plan cheerfully proposes building all of it a second time. First move is a copy, taken with the tool that still works today. Take a baseline plan at the same time, because if Terraform already wants to change things, you will have no way to tell migration noise from the drift you already had.

terminal
# local backend: the two files worth freezing
cp terraform.tfstate ~/pre-tofu.tfstate
cp .terraform.lock.hcl ~/pre-tofu.lock.hcl
# remote backend (S3, GCS, azurerm, http): pull a real snapshot instead
terraform state pull > ~/pre-tofu.tfstate
# read the header: who wrote it, in what format, and which ledger this is
jq '{version, terraform_version, serial, lineage}' ~/pre-tofu.tfstate
# baseline: does terraform itself already see changes?
terraform plan -detailed-exitcode > /dev/null; echo "baseline exit=$?"
# and record both binaries involved, exactly
terraform version
tofu version
output
{
"version": 4,
"terraform_version": "1.5.7",
"serial": 137,
"lineage": "9c4a2f0b-6d51-4e83-bd2a-7f1e5c9a0d64"
}
baseline exit=0
Terraform v1.5.7
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v5.31.0
OpenTofu v1.10.0
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v5.31.0

Four fields in that header, four different jobs. version: 4 is the state format, and both tools have written format 4 for every 1.x release, which is the only reason any of this works. terraform_version is the tool that last wrote the file; OpenTofu keeps the field name rather than inventing its own. serial counts writes, going up by one each time either tool saves, so it tells you whether anything touched state since your snapshot. lineage is a random identifier (a UUID, a long string with effectively no chance of colliding) minted when the state was first created and never changed again. A different lineage is a different ledger, not a newer copy of yours. One detail in the version output is worth a second look: tofu lists the provider as registry.terraform.io/hashicorp/aws, because it is reading the plugins Terraform already unpacked into .terraform/. Nothing has been re-resolved yet. Park the snapshot outside the repository and keep it for the length of your rollback window.

The Version Fence Opens Only One Way

That terraform_version field works like the date stamp on a case file. A tool refuses to open a file stamped by a version newer than itself, on the grounds it might not understand everything inside. The check is a plain numeric comparison. It reads two version strings and knows nothing about which project produced either one. So the rule you need is short: install an OpenTofu at least as new as the Terraform that last wrote your state. OpenTofu 1.6 forked from Terraform 1.5.x and every release since has kept reading the same format, so the format is never your problem. The arithmetic can be. The two projects number their releases independently now, and Terraform has run ahead, so a state written by a Terraform 1.13 will lock out an OpenTofu 1.10 on the number alone. Check OpenTofu's migration guide for the exact Terraform version you are leaving, because that guide is written per version.

Going the other way is where it bites people. Once OpenTofu writes the state, the stamp reads 1.10.0, and a teammate still on Terraform 1.5.7 gets refused with "state snapshot was created by Terraform v1.10.0, which is newer than current v1.5.7". Terraform names itself in that message even though OpenTofu wrote the file, because all it read was a version string. Treat that as a happy accident, not a control. If somebody's Terraform is numerically newer than your OpenTofu, no fence exists at all, and both tools will take turns writing the same ledger without complaining once.

A second hazard lives in the configuration, not the state. Both projects have kept shipping language features since the fork, and each has a few the other lacks. If your config uses something OpenTofu has not matched, it fails at tofu init or tofu plan with a parse error while the state itself reads perfectly. Same cure: migrate from a Terraform version you control, rather than from whatever the fastest-moving person on the team installed last week.

Never patch the version stamp by hand to get past a fence. Editing terraform_version silences the check and changes nothing else, which means you have removed a warning and kept the mismatch. Editing version is worse, because that number declares how the rest of the document was encoded, and lying about it lets resources decode wrong, silently, in the one file you cannot re-create. Install the correct binary instead. If you truly need to move a state backwards, restore your snapshot rather than editing the live copy.

Read The Lock File Diff Like A Security Review

tofu init does what terraform init did: reads the backend, resolves providers, unpacks them into .terraform/, and updates .terraform.lock.hcl. One thing shifts underneath. The default registry, the directory that turns the short name "hashicorp/aws" into a download URL and a set of checksums, is now registry.opentofu.org instead of registry.terraform.io. Think of it as swapping phone books, not phone numbers. Same provider builds, published by the same vendors, different directory in front of them.

terminal
# same directory, same backend config, same state. only the CLI changed.
tofu init
output
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.31.0...
- Installed hashicorp/aws v5.31.0 (signed, key ID 34365D9472D7468F)
Providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://opentofu.org/docs/cli/plugins/signing/
OpenTofu has made some changes to the provider dependency selections recorded
in the .terraform.lock.hcl file. Review those changes and commit them to your
version control system if they represent changes you intended to make.
OpenTofu has been successfully initialized!

That last message is your cue to actually review, not to scroll past. The lock file is the list of provider builds your team has agreed to trust, pinned by exact version and by checksum, and you have just regenerated it. Two kinds of hash live in there. The zh: entries are the vendor's own published checksums, taken from the signed checksum document that ships with each release. The h1: entry is the tool's hash of the unpacked contents. What you want to see is boring: identical versions, identical h1: and zh: values, and only the registry hostname in the block header changing. They match because OpenTofu's registry hands out the vendor's original release artifacts rather than rebuilding them, so the bytes being hashed are the same bytes. If a checksum moved for a version that did not, stop and find out why before you commit.

terminal
# what changed in the file that records what you trust
git diff .terraform.lock.hcl
# record checksums for every platform your team and CI run on,
# not only the one that happened to run init
tofu providers lock -platform=linux_amd64 -platform=darwin_arm64
# any source address still pinning the old registry by hand?
grep -rnE 'registry\.terraform\.io' --include='*.tf' --include='*.tofu' .
output
diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl
index 8f3a1c2..b7d90e4 100644
--- a/.terraform.lock.hcl
+++ b/.terraform.lock.hcl
@@ -1,5 +1,5 @@
-provider "registry.terraform.io/hashicorp/aws" {
+provider "registry.opentofu.org/hashicorp/aws" {
version = "5.31.0"
constraints = "~> 5.0"
hashes = [
"h1:atP1CN4Bh4qXwF/RCVYnCFPPvJt7ekPuAO+m1nCLnKw=",
- Fetching hashicorp/aws 5.31.0 for linux_amd64...
- Retrieved hashicorp/aws 5.31.0 for linux_amd64 (signed, key ID 34365D9472D7468F)
- Fetching hashicorp/aws 5.31.0 for darwin_arm64...
- Retrieved hashicorp/aws 5.31.0 for darwin_arm64 (signed, key ID 34365D9472D7468F)
Success! OpenTofu has updated the lock file.
Review the changes in .terraform.lock.hcl and then commit to your
version control system to retain the new checksums.
./modules/network/versions.tf:6: source = "registry.terraform.io/hashicorp/aws"

That grep hit is the quiet failure mode. A source address with the hostname written out in full is honoured exactly as written, the lock keeps the old hostname, and your migration looks finished while those providers still arrive from the registry you meant to leave (and from a tool HashiCorp's registry terms were never written to cover). Strip the hostname and let the default apply. Then commit the regenerated lock, because it is a repository file rather than state, and CI (continuous integration, the automated runner that plans and applies for you) resolves against it. One more phone call to make: whoever owns outbound network policy. A proxy allowlist built for registry.terraform.io and releases.hashicorp.com will now fail, and adding registry.opentofu.org is not enough on its own, because the registry answers with download URLs pointing at wherever each vendor publishes releases, very often GitHub. Run one tofu init through the proxy with logging turned on and read the real host list out of the log instead of guessing it.

The Empty Plan Is The Acceptance Test

terminal
tofu plan -detailed-exitcode
echo "exit=$?"
output
aws_s3_bucket.logs: Refreshing state... [id=acme-logs-prod]
aws_iam_role.deploy: Refreshing state... [id=acme-deploy]
aws_instance.bastion: Refreshing state... [id=i-0a1b2c3d4e5f60718]
No changes. Your infrastructure matches the configuration.
OpenTofu has compared your real infrastructure against your configuration and
found no differences, so no changes are needed.
exit=0

-detailed-exitcode turns the verdict into something a script can gate on: 0 means no changes, 2 means changes are pending, 1 means the command itself failed. That is the difference between a human squinting at a diff at 6pm and a migration job that refuses to continue. If you get 2, hold it up against the baseline you took before you started. That is what the baseline is for. A plan that wants to add, change or replace things almost always means a provider resolved to a different version, or a source address moved, or a variable your pipeline sets is missing from your shell. Fix the resolution. Do not touch the infrastructure to make the plan go quiet.

One step remains, and it is the one people skip. An empty plan proves you can read the ledger. It says nothing about writing it. Make a trivial, reversible change (a tag, a description field) and apply that. You are exercising the write path end to end: backend credentials, lock acquisition, state upload. Notice what this costs you. Until the first apply, the stored state is still byte for byte the file Terraform wrote, and rolling back is free. After it, the header says OpenTofu.

The cutover, in the order that keeps it reversible
1Baseline and snapshot
empty terraform plan, then state pull plus the lock file, stored off-repo
2Check the version fence
tofu's version number must be at least the writing terraform's
3tofu init
lock file re-keyed to registry.opentofu.org
4Review the lock diff
versions and hashes identical, only the host changes
5tofu plan -detailed-exitcode
exit 0, or stop and find the cause
6Canary apply
one tag change proves the write path and the lock
7Fence terraform
shim the old binary so nothing re-stamps the state

What Comes Across, And What Stays Behind

Almost nothing in your configuration is Terraform-specific, including the parts people expect to rip out. The settings block is still called terraform, so you rename nothing. The working directory is still .terraform/ and the default local state file is still terraform.tfstate. Every TF_-prefixed environment variable your pipelines already set keeps working: TF_VAR_*, TF_WORKSPACE, TF_CLI_ARGS, TF_IN_AUTOMATION, TF_LOG, TF_DATA_DIR. The cloud block parses too, so a project pointed at HCP Terraform (HashiCorp's hosted service, formerly Terraform Cloud) or Terraform Enterprise still loads.

The platform is where the fork gets real. Remote runs on HCP Terraform execute HashiCorp's Terraform, not tofu, so a cloud block in remote execution mode means your migration stops at the command line on your laptop. Keep the block and switch the workspace to local execution if you want the platform purely as a state store, or move to a standard backend and leave. The proprietary layer does not travel at all: Sentinel policy sets, run tasks, drift detection and no-code modules are HashiCorp features with no OpenTofu equivalent. Budget for replacements, such as policy as code with OPA (Open Policy Agent) or Conftest, and a scheduled plan job to catch drift, rather than discovering the gap on cutover morning.

backend.tf
# Two alternatives. A directory may contain one of them, never both.
# STAYING on HCP Terraform or Terraform Enterprise: keep the block, but set
# the workspace to local execution, or the runs happen on their servers,
# using Terraform.
terraform {
cloud {
organization = "acme"
workspaces { name = "prod" }
}
}
# LEAVING the platform: swap for a standard backend, then run
# tofu init -migrate-state
terraform {
backend "s3" {
bucket = "acme-tofu-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
# native S3 locking, recent OpenTofu only.
# older versions: dynamodb_table = "acme-tofu-locks"
use_lockfile = true
}
}

The File That Quietly Decides What You Trust

One piece of the swap moves a security boundary without showing up in any diff. Two rulebooks now sit on the same shelf. On Linux, Terraform reads ~/.terraformrc. OpenTofu reads ~/.tofurc first and falls back to ~/.terraformrc only when the first one is absent. So if your organisation hardened provider installation by shipping a .terraformrc that pins everything to an internal mirror, anyone who drops a .tofurc into a home directory takes that hardening straight out of the path. Nothing errors. The next tofu init reaches somewhere else, and the plan output looks completely normal.

~/.tofurc
# hardened: providers may come from one mirror and nowhere else
provider_installation {
network_mirror {
url = "https://mirror.internal.acme/providers/"
include = ["*/*"]
}
direct { exclude = ["*/*"] }
}
# what to hunt for. dev_overrides loads a provider straight off disk and
# skips BOTH the lock file comparison and the signature check:
#
# provider_installation {
# dev_overrides { "hashicorp/aws" = "/home/build/aws-provider" }
# direct {}
# }

dev_overrides exists so provider authors can test an unreleased build, and it does exactly what it advertises. The named provider loads from a local path with no version, no comparison against the checksums in .terraform.lock.hcl, and no signature verification. OpenTofu is not silent about it. Every plan prints "Warning: Provider development overrides are in effect" with the path listed underneath. A CI log nobody reads is still a fine hiding place for a warning. In pipelines, pin the file with TF_CLI_CONFIG_FILE=/etc/opentofu/ci.tfrc (OpenTofu still honours that variable) so no home directory gets a vote on where your providers come from, and alert on that warning string.

A .tofu file can shadow the .tf file you reviewed
OpenTofu 1.8 added the .tofu extension with a blunt rule: if main.tf and main.tofu both exist in a directory, OpenTofu loads main.tofu and ignores main.tf entirely. It is a genuinely useful escape hatch for modules that serve both tools, and it is a review blind spot. Terraform never loads .tofu files, so a pull request can look harmless to anyone reading .tf, while any CI check globbing *.tf (formatters, linters, policy scans, secret scanners) skips the file that actually runs. Widen every glob to cover *.tf and *.tofu, and treat a new .tofu appearing beside an existing .tf as something to read line by line.

Migrate Consumers First, Shared Stacks Last

Most estates are several state files rather than one, with stacks reading each other's outputs through the terraform_remote_state data source. That creates an ordering problem single-project guides skip, and reading someone else's state runs the same version check as opening your own. Compatibility runs one way: OpenTofu reads state Terraform wrote, but Terraform may refuse state OpenTofu wrote, on the version number alone, and refuses it outright once OpenTofu-only features such as client-side state encryption are in play. So migrate the stacks that only consume outputs first, and leave the shared stacks everybody reads for last. Do it the other way around and the moment you migrate the network stack, every still-on-Terraform stack that reads it starts failing at plan time, which turns a reversible swap into an outage in somebody else's pipeline.

Fence The Old Binary

After the first tofu apply, running terraform apply in the same directory is not something the tools reliably catch for you. Sometimes the version stamp saves you. Often it does not, because a Terraform numbered above your OpenTofu reads the state happily, writes it back, re-stamps the ledger the other way, and drags you into a slow tug of war nobody notices until a plan goes strange. Make it loud instead. An alias in ~/.bashrc covers your own shell and does nothing in CI, since non-interactive shells and scripts never expand aliases. A shim on PATH works everywhere, as long as its directory comes before the real binary's.

/usr/local/bin/terraform
#!/bin/sh
# Retired 2026-03-01: every project on this host is OpenTofu-managed.
# Running terraform against tofu-written state re-stamps it and splits the ledger.
echo "terraform is retired on this host. Use 'tofu' (see docs/migration.md)." >&2
exit 1
terminal
sudo install -m 0755 /etc/opentofu/terraform-shim.sh /usr/local/bin/terraform
hash -r # drop the shell's cached path to the old binary
# a straggler script, or old muscle memory, now fails closed
terraform plan
echo "exit=$?"
# then find the stragglers before they find you
grep -rnE 'terraform (init|plan|apply|destroy)' .github/ ci/ scripts/
output
terraform is retired on this host. Use 'tofu' (see docs/migration.md).
exit=1
.github/workflows/nightly-drift.yml:23: terraform plan -detailed-exitcode
scripts/bootstrap.sh:11:terraform init -input=false
Quick check
01Your estate has two stacks: 'network' publishes outputs that 'app' reads through terraform_remote_state. You are moving to OpenTofu one stack at a time. Which goes first?
Incorrect — Wrong direction. That leaves 'app', still on Terraform, reading a state file OpenTofu just wrote, which is the one direction that can fail.
Correct — Migrate consumers first and shared producers last, so no still-on-Terraform stack is ever forced to read an OpenTofu-written state.
Incorrect — The format number does match, but the writer's version stamp is compared as a plain number, and OpenTofu-only features such as client-side state encryption can stop Terraform reading the file at all.
Incorrect — Nothing needs re-importing. The state is compatible in the direction you are moving, which is the entire point of the fork.
02Before migrating you snapshot state, and the lesson tells you to check the header's lineage and serial. If two state files have different lineage values, what does that tell you?
Incorrect — The field that increments on every write is serial; lineage does not change once set.
Incorrect — The last writer is recorded in terraform_version, not in lineage.
Correct — A different lineage means a different ledger; it is a random ID set at creation and never changed, so it cannot simply be "newer."
Incorrect — The format lives in version (still 4 for both tools); lineage carries no format information.
03After tofu init and a clean empty plan, grep finds a module still declaring source = "registry.terraform.io/hashicorp/aws". What is actually happening, and what should you do?
Incorrect — A fully written-out hostname is honored, not rejected; init succeeds and the plan can be clean while the provider still comes from the old registry.
Correct — The lock keeps the old host and the migration only looks finished; removing the hostname lets the default registry take over.
Incorrect — An explicit hostname overrides the default, which is precisely why this address needs fixing.
Incorrect — The lesson flags this as the quiet failure mode to remove, not a recommended practice.

Write the rollback down before you need it, and keep it valid for the length of your window. If OpenTofu has not applied yet, going back is removing the shim and running terraform init then terraform plan. If it has applied, restore the snapshot: check that the lineage in ~/pre-tofu.tfstate matches the live state, then run terraform state push ~/pre-tofu.tfstate using the Terraform version recorded inside that file. A lower serial needs -force, and that flag is precisely the prompt to stop and check the lineage first. Anything applied after the snapshot is not in it, so be honest about the window. A day or two of real use, not a quarter.

Try this

Run cp terraform.tfstate ~/pre-tofu.tfstate on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a .tofu file can shadow the .tf file you reviewed. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related