OpenTofu is not Terraform with a different binary name, and treating it as one means missing the reasons to use it.
It began as a fork and remains broadly configuration-compatible: the same HCL, the same providers, the same state format, the same core workflow. It has also shipped features Terraform does not have, and some of them — client-side state encryption in particular — change decisions this cluster has already covered.
This lesson covers the workflow and the differences. The comparison lesson covers choosing.
The workflow
Section titled “The workflow”Identical in shape to Terraform’s, which is the point of a compatible fork.
tofu fmt -check -recursive -difftofu init -backend=falsetofu validatetofu inittofu plan -input=false -lock-timeout=5m -out=tofuplantofu apply tofuplanThe same rules apply and for the same reasons: plan on pull requests with read-only credentials, apply after merge behind an approval, state in a remote backend with locking, never apply on arbitrary pull request code.
Everything in the Terraform Git workflow transfers. What follows is what does not.
What genuinely differs
Section titled “What genuinely differs”Four capabilities that exist in OpenTofu and not in Terraform, ordered by how much they affect repository engineering.
State and plan encryption
Section titled “State and plan encryption”The most consequential difference, because it changes an operational constraint rather than adding convenience.
Terraform state contains secrets in plaintext. Server-side encryption on the bucket protects against somebody obtaining the storage; it does not protect against somebody with read access to the bucket, because storage decrypts transparently for them.
OpenTofu encrypts state client-side, before it leaves the machine. Multiple key providers are supported, including PBKDF2 (a passphrase), AWS KMS, GCP KMS and OpenBao.
terraform { encryption { key_provider "aws_kms" "primary" { kms_key_id = "arn:aws:kms:eu-west-1:111111111111:key/EXAMPLE-KEY-ID" region = "eu-west-1" key_spec = "AES_256" }
method "aes_gcm" "default" { keys = key_provider.aws_kms.primary }
state { method = method.aes_gcm.default enforced = true }
plan { method = method.aes_gcm.default enforced = true } }}enforced = true is worth setting deliberately: it makes OpenTofu refuse to read or write unencrypted state rather than silently falling back. Without it, a misconfiguration can leave you writing plaintext while believing you are not — which is the worst of both positions, because you have taken on the key-management burden without getting the protection.
Key providers available include PBKDF2 (passphrase-derived), AWS KMS, GCP KMS, Azure Key Vault and OpenBao, plus an experimental external provider that shells out to a command of your own. The method is AES-GCM, with an unencrypted method that exists specifically for migration.
What that changes for repository engineering:
Bucket read access no longer implies secret access. Somebody with read on the state bucket sees ciphertext. They need the KMS key as well.
Plan files can be encrypted too, which addresses the plan-artifact problem directly. The CI/CD lesson noted that saved plans contain the same sensitive attributes as state and therefore need careful storage; encrypted plans make that materially less fraught.
Key management becomes your responsibility. This is the trade. Lose the key and the state is unrecoverable — versioning does not help, because every version is encrypted with it. Key rotation, key access policy and key backup are now part of your infrastructure operations.
Migration is a defined path, built around a fallback block. On reads, OpenTofu tries the primary method and falls back if it fails; on writes, only the primary applies. That asymmetry is what lets state convert gradually.
To encrypt existing plaintext state, make unencrypted the fallback:
state { method = method.aes_gcm.default fallback { method = method.unencrypted.migrate } }The next read succeeds against the plaintext state; the next write encrypts it. Once every state file has been written at least once, remove the fallback and set enforced = true.
Key rotation uses the same mechanism with the old key as the fallback rather than unencrypted. Removing the fallback afterwards is the step that actually completes a rotation — leave it in place and the old key still decrypts everything, which means the rotation has not achieved anything.
Early variable evaluation
Section titled “Early variable evaluation”Variables and locals are usable in places Terraform evaluates too early to allow: module sources, backend configuration and the encryption block itself.
The backend configuration limitation is the practical one. In Terraform:
terraform { backend "s3" { bucket = var.state_bucket # Error }}That is why partial configuration and -backend-config flags exist. OpenTofu permits the variable directly, as long as it does not depend on resources, data sources or module outputs.
For repository engineering, this removes a recurring source of friction: environment directories can share a backend block that varies by variable, rather than each carrying its own hard-coded values or a separate -backend-config file that CI must remember to pass.
The same applies to module sources, which allows a version to be a variable — useful, and worth using carefully, because a module version that varies by input is harder to audit than one written literally in the source string.
Provider for_each
Section titled “Provider for_each”OpenTofu supports for_each on provider configurations. In Terraform, deploying the same resources across many regions or accounts means writing a provider alias per target and repeating the module call.
For a repository this is a structural improvement: a multi-region estate can be a list variable rather than a generated set of directories, and adding a region becomes a values change rather than a code change. It is also the feature most likely to make a module incompatible with Terraform, so a shared module repository has to weigh the ergonomics against its audience.
The .tofu file extension
Section titled “The .tofu file extension”OpenTofu reads .tofu files, and where a .tofu file exists it takes precedence over an identically named .tf file, which OpenTofu then ignores.
The purpose is compatibility: a repository can carry main.tf that both tools parse, plus main.tofu using OpenTofu-only features. Terraform reads the .tf; OpenTofu reads the .tofu.
Useful during a migration or in a module intended for both audiences. Also a hazard worth documenting in the repository, because a reader editing main.tf and seeing no effect will be baffled — the file being read is the other one.
Structurally identical to the Terraform pipeline. Two workflows, plan on pull requests with a read-only role, apply after merge behind an environment approval.
- uses: opentofu/setup-opentofu@v1 with: tofu_version: ${{ vars.TOFU_VERSION }} tofu_wrapper: false - name: Plan run: | cd environments/production tofu init -input=false tofu plan -input=false -lock-timeout=5m -no-color -out=tofuplan tofu show -no-color tofuplan > plan.txt tofu show -json tofuplan > plan.jsonThe same details matter: disable the wrapper so exit codes and output are yours to handle, pin the version, and treat the plan output as sensitive.
With encryption enabled, the CI role additionally needs permission to use the encryption key. This is a real operational consideration: a plan job that can read the state bucket but cannot use the KMS key fails with an error about the key rather than about state, which is confusing the first time.
-detailed-exitcode behaves as in Terraform: 0 for no changes, 1 for an error, 2 for changes present. Any script handling that inversion transfers unchanged.
Other differences worth knowing
Section titled “Other differences worth knowing”Beyond the four above, a handful of smaller divergences that occasionally matter in a repository.
for_each on import blocks. Importing many existing resources into state is a common migration task, and doing it as a loop rather than as one block per resource is materially less tedious.
Provider mocking in tests. tofu test can mock provider responses, which allows module tests that assert on planned values without contacting a provider at all. For a module repository that wants meaningful CI without a cloud account, this is a genuine capability difference.
Dynamic provider-defined functions. Providers can expose functions that configuration calls directly.
Registry. OpenTofu operates its own provider and module registry. In practice the providers are the same builds; the addressing goes through a different service, which matters if your network restricts egress or if you mirror registries internally.
None of these change the workflow. They change what is possible inside it, and the first two are the ones most likely to affect how you structure a repository.
Providers and modules
Section titled “Providers and modules”The provider ecosystem is shared in practice. OpenTofu operates its own registry and consumes the same provider binaries, built with the same plugin SDK. Provider source addresses in configuration resolve through OpenTofu’s registry.
Modules are compatible. A module written for Terraform generally works with OpenTofu unless it uses a Terraform-only feature. The converse is not true: a module using OpenTofu-only features — provider for_each, early evaluation in a module source — will not work in Terraform.
This matters for shared module repositories. A module published for both audiences must restrict itself to the common subset, or ship .tofu variants. Say which in the README; a consumer discovering the incompatibility at init time will not enjoy it.
Version constraints work the same way, and .terraform.lock.hcl is still the dependency lock file to commit. The filename is unchanged, which is convenient during a migration and mildly confusing afterwards — like the terraform block keyword, it is a compatibility decision rather than an oversight.
Migrating from Terraform
Section titled “Migrating from Terraform”-
Check your provider versions are available through OpenTofu’s registry. Almost all are; verify the ones you depend on rather than assuming.
-
Check for Terraform-only features in your configuration and modules. The list is short and version-dependent — consult OpenTofu’s migration documentation for the version you are moving to.
-
Migrate in a disposable environment first. A throwaway account, or dev.
-
Install OpenTofu alongside Terraform. Both binaries can coexist; you are not committing to anything yet.
-
Run
tofu initand thentofu planagainst existing state. The plan should be empty. This is the verification step and the one that must not be skipped — an empty plan proves OpenTofu reads your existing state and configuration identically. -
Investigate any non-empty plan before proceeding. A difference here is a genuine incompatibility, and finding it in dev is the entire purpose of steps 3 to 5.
-
Update CI to install OpenTofu and use
tofu. Keep both in the workflow briefly if you want a comparison. -
Migrate environments one at a time, lowest first, leaving time between each.
-
Adopt OpenTofu-only features afterwards, deliberately, as separate changes. Enabling state encryption in the same change as the migration means two things to debug at once.
State format is compatible in the direction that matters for migration. Going back is not something to assume — if you have adopted OpenTofu-only features, and especially if you have encrypted state, reversal is a project rather than a command. Treat step 6 as the decision point, while the only thing you have changed is which binary runs.
The terraform block keyword remains terraform, not tofu. Configuration does not need editing for this.
Running both tools in one organisation
Section titled “Running both tools in one organisation”A realistic intermediate state, and one worth planning rather than drifting into.
Per-repository choice is workable. Different teams on different tools, each repository pinning and documenting its own. The cost is cognitive: an engineer moving between repositories must notice which is which.
Shared modules are where it hurts. A module repository consumed by both must stay within the common subset, which means giving up the OpenTofu-only features in exactly the code most likely to benefit from them. The alternatives are maintaining .tofu variants, or splitting the module repository by tool — both of which are real costs.
CI configuration doubles. Reusable workflows help, and the workflow now needs a tool parameter.
Decide the direction and set a date. The failure mode is an organisation that has been “evaluating OpenTofu” for two years with a third of its repositories on each and shared modules stuck at the lowest common denominator. Either is a fine destination; being permanently between them is not.
A neutral note on why teams evaluate at all. The two projects have different governance and different licences, and organisations weigh that differently depending on how they use the tool and what their legal requirements are. That assessment is outside what this site can usefully do for you — it depends on your circumstances, not on engineering properties. What this cluster can tell you is what differs technically, which is the subject of the comparison.
Repository considerations
Section titled “Repository considerations”Pin the version in CI and document it in the README. More important than for Terraform, because fewer people will have OpenTofu installed by default, and a contributor who runs terraform plan in an OpenTofu repository gets errors that do not obviously say “you are using the wrong tool” — particularly if the configuration uses none of the divergent features, in which case it may appear to work and then fail on state encryption.
Say which tool the repository uses, prominently. A repository containing .tf files is ambiguous. A line in the README and a pinned version in CI resolve it.
Decide on .tofu files deliberately. If you are OpenTofu-only, do not use them — .tf is fine and less surprising. Reserve .tofu for repositories genuinely serving both tools.
.gitignore is the same, with one addition worth considering: if you use PBKDF2 with a passphrase from a file, that file must never be committed. The state lesson’s default-deny approach to *.tfvars applies to key material with more force.
Common mistakes
Section titled “Common mistakes”Treating it as a rename. sed s/terraform/tofu/ and declaring the migration done misses both the incompatibilities and the reasons to have migrated.
Migrating without the empty-plan check. The only evidence that state and configuration are read identically.
Enabling state encryption without a key recovery plan. A disclosure risk traded for an availability risk.
Forgetting the KMS permission in CI. Plan fails with a key error that does not obviously mean “add a policy statement”.
Publishing a module using OpenTofu-only features without saying so. Terraform consumers fail at init.
Using .tofu files in an OpenTofu-only repository. Adds a shadowing rule with no benefit.
Migrating every environment at once. Removes the ability to find a difference somewhere cheap.
Assuming the fork means feature parity in both directions. It does not, and the gap grows in both.
Encryption and the rest of this cluster
Section titled “Encryption and the rest of this cluster”State encryption changes several conclusions the cluster reached earlier, and it is worth being explicit about which.
“Read access to state is read access to secrets” — softened. With client-side encryption, bucket read access yields ciphertext. Key access becomes the thing to control, which is a smaller and more auditable grant than storage access.
“Plan files are as sensitive as state” — still true, and now manageable. Encrypted plan files can be stored as ordinary artifacts, which makes the saved-plan apply pattern considerably more practical.
“Separate state storage per environment” — still worth doing, and the argument shifts. The point is no longer only about who can read the file; it is about blast radius and key separation. A key per environment gives you the same property with a different mechanism.
“Never commit state” — completely unchanged. Encryption does not make state a source artifact. It still changes on every apply rather than every commit, still has no meaningful merge resolution, and still has no locking in Git. The reasons that survive encryption are the structural ones, and they were always the stronger reasons.
That last point is worth dwelling on, because it is the predictable misreading: somebody enables encryption and concludes that state is now safe to commit. It is not. The disclosure risk was one of five reasons, and the other four are untouched.
Mental model
Section titled “Mental model”OpenTofu is compatible where compatibility matters — HCL, providers, state format, workflow — and divergent where it has chosen to be. The workflow transfers unchanged; the capabilities do not.
That framing keeps both errors away: assuming it is a drop-in rename, and assuming it is a different tool that needs relearning. It is neither.
What you learned
Section titled “What you learned”- The Git workflow is unchanged: plan on pull requests, apply after merge, remote locked state
- State and plan encryption is client-side, with several key providers, and it moves rather than removes risk
- Early variable evaluation permits variables in backend configuration, module sources and the encryption block
- Provider
for_eachmakes multi-region and multi-account estates a values problem - A
.tofufile shadows an identically named.tffile - Migration is verified by an empty plan against existing state, per environment
- Modules using OpenTofu-only features are not consumable by Terraform
Exercise
Section titled “Exercise”Use a disposable repository with OpenTofu installed locally. No cloud credentials — local_file and random_password are enough.
-
Create a configuration with a
random_passwordand apply it withtofu. Inspect the state file. Predict: is the password plaintext? -
Add an
encryptionblock using the PBKDF2 key provider with a passphrase from an environment variable. Apply again. Inspect state. Predict: what changed? -
Unset the passphrase and run
tofu plan. Predict: what happens, and what does that tell you about key management? -
Create
main.tfandmain.tofuwith different content. Runtofu plan. Predict: which file is used? -
Try a variable in a backend block. Compare the result with what Terraform does with the same configuration.
-
If you have a Terraform configuration available, run
tofu initandtofu planagainst its existing local state. Predict: is the plan empty? -
Delete the repository and the local state.