Infrastructure engineering for East Africa's operators, platforms and regulators See the work →
Terraform to OpenTofu Migration: What Breaks and Why
DevOps 8 min read

Terraform to OpenTofu Migration: What Breaks and Why

Terraform to OpenTofu migration, tested on Terraform 1.16.6 and OpenTofu 1.13.1: the lock file, ignored version pins, encrypted state, and who should stay.

CE
CloudSpinx Engineering
Infrastructure Engineering, Virtualization, Networking, Automation
20 October 2026
opentofuterraformterraform vs opentofuopentofu migration

We moved a Terraform 1.16.6 configuration to OpenTofu 1.13.1, then moved it back, and the resources never noticed. The plan on each side of the move came back with no changes. A Terraform to OpenTofu migration does not change a resource by itself, and any change would have shown in those plans. What it touches is the lock file, every provider address in your state, the version pin you thought was guarding the pipeline, and some of the configuration written for Terraform 1.14 and later.

CloudSpinx writes and hands over infrastructure as code for engineering teams in Kenya and across East Africa, OpenTofu unless a client's tooling needs Terraform, so we ran the move ourselves before telling anyone to make it. That work sits in our DevOps and infrastructure-as-code practice. Four binaries, each checked against its vendor's SHA256SUMS: Terraform 1.16.6, OpenTofu 1.13.1, and OpenTofu 1.12.0 and 1.11.14 to pin down when one behavior changed. The providers were random, null and local, so nothing left the working directory and no remote backend was involved.

Is the Terraform license a reason to move?

For most teams, no. Terraform has shipped under the Business Source License since 1.6.0, and its license file now names IBM as the licensor. The grant lets you "make production use of the Licensed Work", unless that use includes "offering the Licensed Work to third parties on a hosted or embedded basis" to compete with IBM's paid versions. Running your own estate is not that. Selling a platform that runs Terraform for other companies, overlapping what IBM sells, is.

Each version also converts to MPL 2.0 four years after it is published, so Terraform 1.6.0, released on 4 October 2023, becomes available under MPL 2.0 in October 2027.

The honest reasons to move are features and governance. OpenTofu is MPL 2.0 under the Linux Foundation, and it has state encryption, for_each on provider blocks and an -exclude flag for plans. We tried all three on Terraform 1.16.6 and it refused each one. It is a fair question to put to any DevOps consultant you are vetting: which of the two do they write, and what did their last move break?

What the move does to the lock file and the state

The first tofu init in a directory Terraform has been using reinstalls the same provider versions and rewrites the lock file on the way (output trimmed):

$ tofu init
...
Warning: Dependency lock file entries automatically updated
OpenTofu automatically rewrote some entries in your dependency lock file:
  - registry.terraform.io/hashicorp/local => registry.opentofu.org/hashicorp/local
  - registry.terraform.io/hashicorp/null => registry.opentofu.org/hashicorp/null
  - registry.terraform.io/hashicorp/random => registry.opentofu.org/hashicorp/random
The version selections were preserved, but the hashes were not because the
OpenTofu project's provider releases are not byte-for-byte identical.

tofu plan found no changes. The first tofu apply, with nothing to apply, still rewrote the state: terraform_version went from 1.16.6 to 1.13.1, and every resource's provider moved from registry.terraform.io to registry.opentofu.org.

None of that is a trap on its own. Going back worked. terraform init fetched the providers from both registries so it could read that state, which left six entries in the lock file, terraform plan found no changes, and the next terraform apply put every address back. An edit after that planned an ordinary replacement, so Terraform owned the resources again.

The trap is the pipeline. A CI job that runs init -lockfile=readonly, which is the right setting for CI, fails on its first OpenTofu run with Error: Provider dependency changes detected and exit code 1, while the lock file in the repository stays exactly as Terraform wrote it. Commit the rewritten lock file in the same pull request that swaps the binary. If your pipeline pins the Terraform version in a setup step, that line changes in the same pull request too.

Why OpenTofu ignores required_version in a .tf file

Since 1.12.0, OpenTofu takes required_version only from a file ending in .tofu or .tofu.json. In a .tf file it is accepted and ignored. Our 1.13.1 binary planned without complaint against required_version = ">= 2.0", and again against "< 1.10". The parser's own comment says the argument is "ignored unless it appears in a file named with a ".tofu" or similar suffix that indicates it's intended for OpenTofu rather than its predecessor".

That is a sensible rule, because the pin in a Terraform module describes Terraform. It also means a guard you relied on is gone, and nothing tells you. OpenTofu 1.11.14 does the opposite, reading Terraform's required_version = ">= 1.15" as its own and refusing to run:

Error: Unsupported OpenTofu Core version

The fix the OpenTofu settings page recommends is two files, and it planned cleanly on all four binaries we tried:

# versions.tf
terraform {
  required_version = ">= 1.15"
}
# versions.tofu: when both exist, OpenTofu skips versions.tf
terraform {
  required_version = ">= 1.11"
}

OpenTofu 1.12 also added a language block for the same job. Keep it out of .tf files, because Terraform 1.16.6 stops at Unsupported block type.

Which required_version pin each binary obeys
Which required_version pin each binary obeysTwo layouts of a module's version pins, each read by Terraform 1.16.6, OpenTofu 1.12.0 and 1.13.1, and OpenTofu 1.11.14. With only a versions.tf pinning required_version ">= 1.15", Terraform enforces it, OpenTofu 1.12.0 and 1.13.1 ignore it and pin nothing, and OpenTofu 1.11.14 reads it as its own constraint and refuses to run. With a versions.tofu added beside it, Terraform reads versions.tf and both OpenTofu lines read versions.tofu, so every binary obeys exactly one pin of its own.ONLY versions.tfTerraform 1.16.6enforces >= 1.15OpenTofu 1.12.0, 1.13.1ignores it, plans against any versionOpenTofu 1.11.14Unsupported OpenTofu Core versionversions.tfrequired_version = ">= 1.15"versions.tf PLUS versions.tofuTerraform 1.16.6versions.tf, >= 1.15OpenTofu 1.12.0, 1.13.1versions.tofu, >= 1.11OpenTofu 1.11.14versions.tofu, >= 1.11versions.tfOpenTofu skips itversions.tofuTerraform ignores it
  • obeys this pin
  • no pin in force
  • refuses to run
With only versions.tf, OpenTofu 1.12 and later obeys no pin at all and 1.11 obeys Terraform's. A versions.tofu beside it gives each binary one constraint of its own.

Terraform 1.16.6 ignored every .tofu file we gave it, so adding one changes nothing for a team still on Terraform. That is what makes it safe to do before the move rather than during it.

Terraform 1.14 and later blocks OpenTofu will not parse

The fork has moved in both directions, and a module that has kept up with Terraform's releases will not all come across. We wrote one small configuration per feature and ran init and validate on Terraform 1.16.6 and OpenTofu 1.13.1. Terraform accepted every row.

Written for TerraformAdded inOpenTofu 1.13.1
action block with an action_trigger1.14Unsupported block type
type on an output block1.15Unsupported argument
import block inside a child module1.16Import blocks are only allowed in the root module
store block in terraform_data1.16Unsupported block type
ephemeral resources1.10accepted
lifecycle { destroy = false }1.16accepted
deprecated on a variable1.15accepted

Module sources taken from a variable split the other way. Terraform 1.16.6 insisted on const = true (Only literal values and const variables can be evaluated during init), and OpenTofu took the variable with or without it.

So before anyone swaps a binary, run tofu init and tofu validate in every root module on a branch. It costs nothing, and it turns each refused row into a file and a line number. If your modules use actions, imports inside modules or store blocks, the move includes rewriting those modules, and that rewrite is the expensive part of leaving Terraform. Send the repository layout and your Terraform version through the scoping form if you would rather we ran that sweep and told you what it found.

Technician checking a network rack with a handheld tester, the hardware a Terraform to OpenTofu migration never touches

Encrypted state is the one-way door

Encryption is the best reason to move, and the only change to state we found that Terraform cannot follow you back through. Terraform writes resource attributes to state as plain text: the 24-character random_password we generated sat in terraform.tfstate, readable by anyone holding the file, marked sensitive only in the metadata beside it. OpenTofu encrypts the state body, leaving only the lineage, the serial, a format tag and the key metadata readable. With a key provider, an aes_gcm method and an unencrypted fallback for the existing plain state, all in a .tofu file, which Terraform ignores (its validate still passed), one tofu apply rewrote the state as encrypted_data plus key metadata. Leave out the fallback and tofu init stops at encountered unencrypted payload without unencrypted method configured.

Terraform could not read the encrypted state at all, with or without -lock=false:

Error: Unsupported state file format
The state file does not have a "version" attribute, which is required to
identify the format version.

OpenTofu with the encryption block removed refused as well: This state file is encrypted and can not be read without an encryption configuration.

The way back exists only while you hold the key. Swap the two methods, plain text as the primary and the old method as the fallback, run tofu apply once, and the state is ordinary JSON again. Terraform planned no changes after that, which is the rollback OpenTofu's encryption documentation describes. Lose the key and there is no way back, for either tool.

Move first and encrypt a few weeks later. Until you encrypt, Terraform's own init, one apply and a second init put the lock file and the state back the way Terraform wrote them, so you learn whether OpenTofu suits your team while the door is still open. When you do encrypt, use a cloud KMS key provider rather than a passphrase in a file, keep a backup of the key away from the state bucket, and write the decrypt step into the runbook before anyone needs it.

What survives a Terraform to OpenTofu round trip
What survives a Terraform to OpenTofu round tripFive things a Terraform to OpenTofu migration touches, each showing what happens on the move to OpenTofu 1.13.1 and on the way back to Terraform 1.16.6, from our lab run. The lock file is rewritten on the way over and Terraform adds its own entries back, six in all. The provider addresses in state are rewritten over and rewritten back by Terraform. A required_version pin in a .tf file crosses but OpenTofu 1.12 and later ignores it. Action blocks, store blocks, type on outputs and import blocks inside modules, written for Terraform 1.14 and later, are refused by OpenTofu 1.13.1. State encrypted by OpenTofu cannot be read by Terraform until OpenTofu decrypts it with the key.TERRAFORM 1.16.6OPENTOFU 1.13.1move / backLock fileprovider entries and hashesrewritten to registry.opentofu.org, with new hashesTerraform adds its own entries, six in allStateprovider address per resourcerewritten by the first apply, even with no changesrewritten back by the first terraform applyrequired_versionin a .tf fileaccepted and ignored by OpenTofu 1.12 and laterTerraform enforces it againRefused Terraform 1.14+action, store, output type,import inside a modulerefused by OpenTofu 1.13.1 at init or validatenothing crossed, so nothing to bring backEncrypted statewritten by OpenTofuTerraform has no encryption block to write itUnsupported state file format, until decrypted
  • crosses, comes back
  • crosses, stops working
  • refused
Lock file and state cross both ways. Encrypted state is the only thing that cannot come back without the key, and the four Terraform 1.14 and later constructs OpenTofu refused never cross at all.

Both binaries wrote plain state as format version 4. The encrypted file has no version attribute at all, which is the exact field Terraform's error names.

Terraform vs OpenTofu: who should stay

Stay on Terraform if your runs live in HCP Terraform. Its workspace setting chooses "the Terraform version to use for all operations in the workspace" and the settings page names no other engine, so on our reading the runs have to leave the platform before the binary matters, and that is a project of its own. We did not test whether OpenTofu can keep its state there in local execution mode. Stay too if your modules lean on actions, imports inside modules or store blocks and you sell nothing built on Terraform. The license already permits what you do, and a rewrite buys you nothing you asked for.

Move if you want the tool to encrypt state, or if you sell a hosted service that runs Terraform for other companies, the case the license's competitive-offering clause is about. It is also the default we build on when the choice is ours: one Google Cloud estate we rebuilt as code is thirty-three OpenTofu modules across eight projects.

Most teams sit between the two. Shared modules, CI on GitHub Actions or GitLab, state in a bucket, and none of the refused rows above. Add the versions.tofu files now, since Terraform does not read them. Then move one root module at a time with its lock file in the same commit, and leave encryption for last. We did not test remote backends here, so read every plan before you trust it.

Send us the Terraform version, where your state lives, how many root modules you run and the CI file that runs plan, through the DevOps scoping form or on WhatsApp at +254 713 403 044. We reply with what in this post applies to your code and one figure for the move, or a short note saying stay where you are, free and with nothing to sign. If you go ahead, we handle the move with your team, one root module at a time, and leave the decrypt runbook in your repository.

WhatsApp