Migrate Terraform to OpenTofu in 2026: Encrypt Your State
June 23, 2026
To migrate from Terraform to OpenTofu, run tofu init -upgrade in your existing project, confirm tofu plan shows zero changes, then tofu apply. OpenTofu reads the same .tfstate format, so the swap is near-seamless — and the real payoff is enabling OpenTofu's built-in state encryption afterward, which Terraform's CLI does not provide.
TL;DR
OpenTofu is the MPL-2.0 fork of Terraform, and switching is mostly a binary swap: tofu init -upgrade, tofu plan (expect zero changes), tofu apply. The feature that actually justifies the move is state encryption at rest, which Terraform's open-source CLI still lacks. This guide walks the full migration on a tiny runnable project, then encrypts an existing unencrypted state file with a PBKDF2 passphrase and AES-GCM, using the unencrypted fallback so you never lose your state. Pinned versions: OpenTofu 1.12.0, hashicorp/random 3.9.0. Time: about 25 minutes.
What you'll learn
- Install OpenTofu 1.12 and swap the
terraformbinary fortofucleanly - Why
.terraform.lock.hclprovider hashes change after the swap, and how to regenerate them - Encrypt an existing, unencrypted state file with PBKDF2 + AES-GCM
- Use the
unencryptedfallback method to migrate without bricking your state - Lock encryption down with
enforced = trueso plaintext can never be written again - Encrypt plan files and rotate keys safely with a
fallbackblock - Verify the state is encrypted and fix the five most common migration errors
Why move, and why encryption is the real reason
HashiCorp relicensed Terraform from MPL 2.0 to the Business Source License (BSL 1.1) on 10 August 2023.1 The community forked the last MPL-licensed 1.5 line, and that fork became OpenTofu under the Linux Foundation.1 OpenTofu stays MPL 2.0 and reads the same HCL and the same JSON state format, so for most configurations it is a near drop-in replacement.
But "free as in license" is rarely enough on its own to justify a migration. The concrete operational win is this: OpenTofu ships built-in state and plan encryption, generally available since v1.7, and Terraform's CLI does not provide an equivalent.2 Your state file contains every secret your resources produce — database passwords, private keys, access tokens — in plaintext by default. Encryption at rest closes that gap. The rest of this guide gets you onto OpenTofu and then turns encryption on the right way.
Prerequisites
- A Linux or macOS shell (commands below use Ubuntu/Debian; macOS and Windows install steps are linked).
- An existing Terraform project, or nothing at all — we build a tiny demo project you can run from scratch with OpenTofu alone.
- OpenTofu 1.12.0 or later. v1.12.0 was released 14 May 2026; we pin to it for reproducibility.3 Encryption has been GA since v1.7, so any 1.7+ release works.2
- hashicorp/random provider 3.9.0 — a logical provider that needs no cloud account, so the only network access the tutorial requires is the one-time provider download.4
- Roughly 25 minutes.
One rule before you touch encryption: back up your state file first. An invalid encryption configuration can, in edge cases, cause OpenTofu to write an empty or unreadable state.2 If you use a remote backend (S3, GCS, AzureRM), exercise its restore procedure once before you begin.
Step 1 — Install OpenTofu 1.12
The fastest reproducible path on Debian/Ubuntu is the manual binary, pinned to an exact version:
TOFU_VERSION="1.12.0"
curl -fsSL -o "tofu.zip" \
"https://github.com/opentofu/opentofu/releases/download/v${TOFU_VERSION}/tofu_${TOFU_VERSION}_linux_amd64.zip"
sudo apt-get update && sudo apt-get install -y unzip
unzip tofu.zip tofu
sudo mv tofu /usr/local/bin/
sudo chmod +x /usr/local/bin/tofu
tofu version
Expected output:
OpenTofu v1.12.0
on linux_amd64
Prefer a package manager? Two officially documented options:5
# Snap (note: the snap package is named "opentofu", and --classic is required)
sudo snap install --classic opentofu
# APT (after adding OpenTofu's official repo; the apt package is named "tofu")
sudo apt-get install -y tofu
The snap and apt packages track the latest stable release rather than a pinned version, so for tutorials and CI prefer the pinned binary above.
Step 2 — Validate your Terraform baseline
If you already run Terraform, do not start the migration from a dirty state. Run your existing tooling first and confirm there is no drift:
terraform init
terraform apply # confirm the plan shows "No changes" before continuing
A clean baseline matters because the next steps assume your code and your real infrastructure already agree. If terraform apply wants to change things, resolve that as Terraform before introducing OpenTofu — debugging drift and a tool swap at the same time is how migrations go wrong.
Following along with no existing project? Create the demo. Make a directory and a main.tf:
# main.tf
terraform {
required_version = ">= 1.12.0"
required_providers {
random = {
source = "hashicorp/random"
version = "3.9.0"
}
}
}
# A sensitive value that lands in state in plaintext — exactly what we want to encrypt.
resource "random_password" "db" {
length = 24
special = true
}
resource "random_pet" "stack" {
length = 2
}
output "stack_name" {
value = random_pet.stack.id
}
The random_password.db.result is marked sensitive, so OpenTofu won't print it — but it is written to terraform.tfstate in cleartext. That is the whole point: state holds secrets even when the CLI hides them.
Step 3 — Swap the binary with tofu init -upgrade
Now point OpenTofu at the same directory. OpenTofu always resolves providers from its own registry (registry.opentofu.org) by default, so plain tofu init — exactly what OpenTofu's official migration guide uses — already works.6 The reason to add -upgrade during a migration is the lock file: your existing .terraform.lock.hcl still carries HashiCorp's checksums, and -upgrade tells OpenTofu to re-select the providers and refresh those checksums against its own registry, heading off a checksum-mismatch error on the first run:
tofu init -upgrade
Then confirm parity and complete the migration:
tofu plan # MUST report: No changes. Your infrastructure matches the configuration.
tofu apply # updates the state file format if needed, completing the migration
Three things are happening under the hood, and each one trips people up:
- The state format is identical. OpenTofu reads and writes the same JSON
.tfstate, which is whytofu planshows zero changes.6 On a fresh project,tofu init && tofu applysimply creates that unencrypted state for the first time. - The lock file hashes change.
.terraform.lock.hclkeeps the same format, but provider checksums fromregistry.opentofu.orgdiffer from HashiCorp's.tofu init -upgraderegenerates them; for multi-platform CI, runtofu providers lock -platform=linux_amd64 -platform=darwin_arm64so the lock file covers every runner. - There is a genuine cutover.
tofu applyupdates the state file format if needed; OpenTofu's official guide frames rollback as "restore from your backup, then run Terraform," so keep that backup until you are committed.6 And once you enable encryption in Step 4, Terraform can no longer read the state at all.
One code hygiene note: if your configuration uses fully qualified source addresses like registry.terraform.io/hashicorp/aws, shorten them to hashicorp/aws so OpenTofu resolves them against its own registry.6
At this point you are fully on OpenTofu — with an unencrypted state file. Let's fix that.
Step 4 — Encrypt an existing state with PBKDF2 and AES-GCM
Here is the part every generic migration guide skips. You cannot simply add an encryption block to a project that already has a plaintext state: OpenTofu will refuse to read the unencrypted file, because to OpenTofu an unencrypted payload it expected to be encrypted looks like tampering.2 The fix is a temporary unencrypted fallback that lets OpenTofu read the old plaintext once and rewrite it encrypted.
First, supply the passphrase out of band (never commit it). PBKDF2 requires at least 16 characters:2
export TF_VAR_passphrase="correct-horse-battery-staple"
Now create encryption.tf. The block order below — unencrypted method, key provider, AES-GCM method, then the state block with a fallback — is taken directly from OpenTofu's official migration steps:2
# encryption.tf
variable "passphrase" {
type = string
sensitive = true
}
terraform {
encryption {
# Step 1: a temporary method that can read the existing plaintext state.
method "unencrypted" "migrate" {}
# Step 2: derive a key from the passphrase (min 16 chars; 600,000 PBKDF2
# iterations and SHA-512 are the defaults).
key_provider "pbkdf2" "passphrase" {
passphrase = var.passphrase
}
# Step 3: the real encryption method.
method "aes_gcm" "encrypted" {
keys = key_provider.pbkdf2.passphrase
}
state {
# Step 4: encrypt state with AES-GCM going forward...
method = method.aes_gcm.encrypted
# Step 5: ...but allow reading the old plaintext during this one migration.
fallback {
method = method.unencrypted.migrate
}
}
}
}
Apply it. OpenTofu reads the plaintext through the fallback and rewrites the state encrypted:
tofu apply
You'll see No changes for resources — encryption is a storage concern, not an infrastructure change — but the state file on disk is now ciphertext. OpenTofu always writes with the primary method (aes_gcm) and only falls back to the unencrypted method when reading.2
A few defaults worth knowing, all configurable on the pbkdf2 key provider: key_length defaults to 32, iterations defaults to 600,000 (minimum 200,000), salt_length to 32, and hash_function to sha512.2 The defaults are sensible; tune them only if you have a specific compliance requirement.
Step 5 — Lock it down with enforced = true
Right now your config can still write plaintext, because the unencrypted fallback is still present. Once the migration apply has succeeded and your state is encrypted, remove the fallback and turn on enforcement so a missing or broken encryption config fails loudly instead of silently writing secrets in the clear:2
# encryption.tf — after a successful migration
variable "passphrase" {
type = string
sensitive = true
}
terraform {
encryption {
key_provider "pbkdf2" "passphrase" {
passphrase = var.passphrase
}
method "aes_gcm" "encrypted" {
keys = key_provider.pbkdf2.passphrase
}
state {
method = method.aes_gcm.encrypted
enforced = true # refuse to write unencrypted state, ever
}
}
}
Run tofu plan once more to confirm OpenTofu can still read the encrypted state with your passphrase and reports no changes. With enforced = true, if the passphrase is missing the run fails rather than producing a plaintext file — exactly the safety property you want in CI.
Do not rename
key_provider.pbkdf2.passphraseormethod.aes_gcm.encryptedafter data is encrypted. Those names are stored in the state's encryption metadata, and renaming them will break decryption. If you must rename, set anencrypted_metadata_aliason the key provider, or roll over with afallbackblock (next step).2
Step 6 — Encrypt plan files and rotate keys safely
Two production refinements close out the migration.
Encrypt plan files too. A saved plan (tofu plan -out=tfplan) embeds the same secrets your state does. Add a plan block that reuses the method:
terraform {
encryption {
key_provider "pbkdf2" "passphrase" {
passphrase = var.passphrase
}
method "aes_gcm" "encrypted" {
keys = key_provider.pbkdf2.passphrase
}
state {
method = method.aes_gcm.encrypted
enforced = true
}
plan {
method = method.aes_gcm.encrypted
enforced = true
}
}
}
Rotate the passphrase without downtime. When you change the passphrase (or switch key-management systems entirely), keep the old method available as a fallback so OpenTofu can still read state encrypted under the previous key. It reads with the new method, falls back to the old one, and always writes with the new:2
terraform {
encryption {
key_provider "pbkdf2" "current" {
passphrase = var.passphrase
}
key_provider "pbkdf2" "previous" {
passphrase = var.old_passphrase
}
method "aes_gcm" "new" {
keys = key_provider.pbkdf2.current
}
method "aes_gcm" "old" {
keys = key_provider.pbkdf2.previous
}
state {
method = method.aes_gcm.new
fallback {
method = method.aes_gcm.old
}
}
}
}
Run tofu apply once with both methods present to rewrite the state under the new key, then drop the previous key provider and the fallback. Note that encryption protects state at rest only — it does not defend against state loss, replay attacks using an old state or plan, or the person who holds the passphrase and runs tofu.2 If you back your keys with a KMS and an algorithm like AES-GCM, enable automatic key rotation, because AES-GCM has a finite safe-usage limit per key.2
Remote backends: client-side vs server-side encryption
The demo above uses a local state file, but the same encryption block works unchanged with an S3, GCS, or Azure Blob backend. The important detail is where the encryption happens. OpenTofu encrypts the state on your machine, before it is written to the backend, and decrypts it only when it reads the state back.2 That is fundamentally different from S3 server-side encryption (SSE), where the bytes travel to the bucket and the storage service encrypts them there.
This is exactly the capability gap with Terraform. Terraform's CLI has no client-side state encryption — its open-source PR for the feature was closed as stale — so Terraform users rely on backend-side options like S3 SSE or HCP Terraform's at-rest encryption.2 With OpenTofu you control the key, and the state sitting in the bucket is unreadable even to someone who can list and download every object.
In production you typically want both layers: keep S3 SSE on as a baseline, and add OpenTofu's encryption block for defense in depth. The only operational change for a remote backend is making sure the passphrase (or KMS key access) is present wherever tofu runs — most importantly, in CI.
Verification — prove the state is actually encrypted
The cleanest check is to grep the state for something that used to be plaintext. Before encryption, the resource identifiers and the pet name are right there in the JSON:
# BEFORE encryption — the resource data sits in the state in plaintext
grep -c random_password terraform.tfstate # a non-zero count
grep -o random_pet terraform.tfstate | head -n1 # -> random_pet
After a successful encrypted apply, the entire state payload — resource types, attributes, and the sensitive result — lives inside an encrypted envelope, so the plaintext strings are gone even though the file is still valid JSON:
# AFTER encryption — no plaintext resource data remains
grep -c random_password terraform.tfstate # 0
jq empty terraform.tfstate && echo "still valid JSON (an encrypted envelope)"
If the first grep returns 0 and your tofu plan still reports no changes, the migration succeeded: OpenTofu is reading and writing encrypted state with your passphrase.
Troubleshooting
encountered unencrypted payload without unencrypted method configured — you added an encryption block to a project whose state is still plaintext, without the migration fallback. Add method "unencrypted" "migrate" {} and a fallback { method = method.unencrypted.migrate } to the state block, apply once, then remove them (Step 4).2
Unsupported state file format: This state file is encrypted and can not be read without an encryption configuration — the opposite problem: your state is encrypted but the current run has no encryption config or no passphrase. This is the classic CI failure. Make sure TF_VAR_passphrase (or a full TF_ENCRYPTION block) is present in the pipeline's environment, not just on your laptop.7
Decryption fails after you changed the passphrase — AES-GCM authentication fails when the key is wrong, and OpenTofu cannot read the state. There is no recovery without the exact key, which is why Step 4 insists on a backup.2 Restore the unencrypted backup, then redo the migration, or use a fallback with the old key (Step 6).
Decryption fails after you renamed a key provider or method — the names are embedded in the state's encryption metadata. Revert the rename, or use encrypted_metadata_alias on the key provider so the metadata key stays stable across renames.2
provider ... was previously installed from registry.terraform.io / lock-file hash mismatch — your .terraform.lock.hcl still carries HashiCorp checksums. Run tofu init -upgrade to regenerate it against registry.opentofu.org, and tofu providers lock -platform=... to record hashes for every platform your CI uses (Step 3).6
Next steps and further reading
You now have a fully migrated OpenTofu project with encrypted state and plan files, locked behind enforced = true. From here:
- Move the passphrase into a managed key provider (AWS KMS, GCP KMS, or Vault) instead of PBKDF2, and wire up automatic key rotation.
- Pair encryption with native state locking so concurrent runs stay safe — see our walkthrough of Terraform and OpenTofu S3 native state locking.
- Run
tofu applyonly from CI, never from laptops, so the passphrase and the decrypted secrets never leave the pipeline. Our guide to keyless AWS deploys with GitHub Actions OIDC shows how to do that without long-lived credentials.
For the authoritative details on every key provider and method, keep the OpenTofu state and plan encryption docs open while you work.2
Footnotes
-
OpenTofu, "OpenTofu Announces Fork of Terraform" — HashiCorp's 10 August 2023 BSL 1.1 relicense and the MPL 2.0 community fork. https://opentofu.org/blog/opentofu-announces-fork-of-terraform/ ↩ ↩2
-
OpenTofu docs, "State and Plan Encryption" — PBKDF2/AES-GCM configuration, the
unencryptedmigration method,fallbackblocks,enforced, key rollover,encrypted_metadata_alias, defaults (key_length 32, iterations min 200,000/default 600,000, salt_length 32, hash_function sha512), and the at-rest threat model. https://opentofu.org/docs/language/state/encryption/ ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18 ↩19 -
OpenTofu, "OpenTofu v1.12.0" release announcement (14 May 2026). https://opentofu.org/blog/opentofu-1-12-0/ ↩
-
HashiCorp Terraform Registry, "random provider" (v3.9.0) —
random_passwordandrandom_pet; sensitive values are stored in state. https://registry.terraform.io/providers/hashicorp/random/latest/docs ↩ -
OpenTofu docs, "Installing OpenTofu" — manual binary, Snap (
opentofu,--classic), and APT (tofu) package names and commands. https://opentofu.org/docs/intro/install/ ↩ -
OpenTofu docs, "Migration Guide" —
tofu init -upgrade, the OpenTofu Registry (registry.opentofu.org),.terraform.lock.hclregeneration /tofu providers lock, and shortening fully qualified provider sources. https://opentofu.org/docs/intro/migration/migration-guide/ ↩ ↩2 ↩3 ↩4 ↩5 -
GitLab Forum, "OpenTofu — Unsupported state file format: can not be read without an encryption configuration" — missing encryption config/passphrase in CI. https://forum.gitlab.com/t/opentofu-unsupported-state-file-format-can-not-be-read-without-an-encryption-configuration/120379 ↩