
Optionality
OpenTofu Migration: The Registry Is the Hard Part
Migrating to OpenTofu past the licence debate: how state provider addresses translate, why lock-file hashes do not carry over, and how to mirror the registry.
The licence argument has been had. The migration has not: the layers below the command sequence, where a configuration that planned clean on Monday moves a provider version on Thursday and nobody can say why. This article is that part, read out of OpenTofu's own source, changelog and registry policy — and it ends somewhere more durable than one tool.
What the Relicensing Actually Says
Read the licence rather than the commentary. Terraform's LICENSE file names the Licensor as International Business Machines Corporation and the Licensed Work as "Terraform Version 1.6.0 or later" — IBM having completed its acquisition of HashiCorp on 27 February 2025. Two parameters decide most of what a platform team needs to know.
- Production use is granted. You may use the Licensed Work in production provided your use does not include offering it to third parties on a hosted or embedded basis in order to compete with IBM's paid versions — and using it for internal purposes within an organisation is not a competitive offering.
- The conversion is time-based. The Change Date is four years from each version's publication and the Change License is MPL 2.0, so every BUSL version becomes MPL-licensed on a schedule.
The honest conclusion is uncomfortable for a migration pitch: if you run Terraform to build your own infrastructure, the grant covers you. That is licence text, not legal advice — edge cases belong with counsel. The reason to move is governance, not compliance. OpenTofu is Mozilla Public License 2.0, carrying HashiCorp's original 2014 copyright alongside the OpenTofu Authors' — a fork, not a rewrite — announced by the Linux Foundation on 20 September 2023. The question is not whether today's terms permit today's usage; it is who gets to change them.
The Twenty-Minute Migration, and Why It Is Not the Job
For one directory the mechanical sequence really is short, and OpenTofu's migration guide states the pass condition precisely: you should see "No changes" or the same plan output you would see with Terraform, and if you see unexpected changes you do not apply them, you investigate the differences, and you consider rolling back.
That is a gate, so treat it like one. Reading two plans side by side and calling them "basically the same" is how a migration ships a silent diff.
#!/usr/bin/env bash
# One directory. Run bottom-up across a terraform_remote_state graph.
set -euo pipefail
TOFU_VERSION="1.12.5"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
cd "${1:?usage: migrate.sh <configuration-directory>}"
# 1 — Snapshot what the migration can damage.
terraform state pull >"state-${STAMP}.json"
cp .terraform.lock.hcl "lock-${STAMP}.hcl"
# 2 — The comparison baseline: the plan Terraform produces today.
terraform plan -out=terraform.tfplan
terraform show -no-color terraform.tfplan >"plan-terraform-${STAMP}.txt"
# 3 — Verify the binary (fetched out of band) before it touches the backend.
sha256sum --check --ignore-missing "tofu_${TOFU_VERSION}_SHA256SUMS"
tofu version | grep -F "v${TOFU_VERSION}"
# 4 — Initialise, plan, diff.
tofu init -input=false
tofu plan -out=tofu.tfplan
tofu show -no-color tofu.tfplan >"plan-tofu-${STAMP}.txt"
# 5 — Stricter than OpenTofu's pass condition, so it also trips on harmless
# formatting churn. Read every failure; diff "tofu show -json" if UI text moves.
if diff -u "plan-terraform-${STAMP}.txt" "plan-tofu-${STAMP}.txt"; then
echo "PASS: OpenTofu produces the plan Terraform produced."
else
echo "STOP: plans differ. Do not apply." >&2
echo "Roll back to state-${STAMP}.json and lock-${STAMP}.hcl." >&2
exit 1
fiState: How Provider Addresses Are Really Translated
Every resource in a state file records its managing provider as a fully qualified address — registry.terraform.io/hashicorp/aws, not aws. OpenTofu's default registry is registry.opentofu.org. If nothing reconciled those strings, the first plan would propose replacing your estate.
Something does, documented in the code rather than the docs. From internal/tofumigrate/tofumigrate.go: the function "can be used to update the in-memory view of the state to use registry.opentofu.org provider addresses", and "this only applies for providers which are *not* explicitly referenced in the configuration in full form." Three consequences follow, none visible in the output.
- The rewrite is in memory. Until something writes state, the stored file still holds
registry.terraform.ioaddresses; OpenTofu translates its view at load time. Anything reading that state by other means — a tool, an audit script — sees the old addresses and is not wrong. - A full-form pin suppresses it. Given
source = "registry.terraform.io/hashicorp/random", the comment says "then we keep the old address." Pinning the hostname, which many teams did deliberately, opts that provider out. - There is a kill switch. The function returns the state untouched when
OPENTOFU_STATEFILE_PROVIDER_ADDRESS_TRANSLATIONis0— useful for reproducing a failure, dangerous left set in CI.
# Suppresses translation: the full source address is pinned, so the in-memory
# view keeps registry.terraform.io/hashicorp/aws.
terraform {
required_providers {
aws = {
source = "registry.terraform.io/hashicorp/aws"
version = "~> 5.60"
}
}
}
# Allows translation: the shorthand carries no hostname, so OpenTofu resolves it
# against its own default registry.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}The Lock File Is Where Migrations Actually Fail
.terraform.lock.hcl records, per provider, a version and a set of checksums. The h1: entries hash the package contents and do not carry over — structural rather than a bug: OpenTofu rebuilds the hashicorp-namespace providers from source and republishes them on its own registry, and rebuilt bytes hash differently. A mismatch is the toolchain working.
OpenTofu 1.10.0 softened the landing. Its changelog records that when tofu init meets a lock file with entries for certain providers on registry.terraform.io, it now attempts to select the corresponding version of the equivalent provider on registry.opentofu.org. The next sentence matters more: this applies only to providers rebuilt from source and republished by the OpenTofu project, "because we cannot assume any equivalence for third-party providers published in other namespaces."
That carve-out has a face. Issue #2775 reported tofu init silently upgrading a provider when reusing a Terraform lock file — a vendor provider in its own namespace, pinned at one version and resolved to a later one. Accepted as a bug, closed as completed on 19 May 2025. But the fix carries the changelog's scope: translation covers only the providers OpenTofu republishes, so a vendor's own namespace is still yours to resolve.
Two version details shape the repair. 1.10.0 began recording a zh: archive checksum alongside h1: where a source offers a .zip but no signed official checksums. And 1.12.0 changed the default: the release notes state that after tofu init the lock file immediately holds the full checksum set for all platforms in both schemes, with tofu providers lock needed only where init installs from an alternative source. Skip the manual pass on 1.12 against the default registry; run a mirror and you need it.
#!/usr/bin/env bash
set -euo pipefail
# A — Persist the address change. A backup is always written and cannot be
# disabled.
tofu state replace-provider -auto-approve \
registry.terraform.io/hashicorp/aws \
registry.opentofu.org/hashicorp/aws
# B — Rebuild the lock across every platform in the fleet, so a Linux runner
# and an arm64 laptop verify the same entries. Needed when init installs
# from a mirror.
rm -f .terraform.lock.hcl
tofu providers lock \
-platform=linux_amd64 \
-platform=linux_arm64 \
-platform=darwin_arm64 \
-platform=windows_amd64
# C — The standing gate: a lock file that moves in CI is a supply-chain event.
git diff --exit-code .terraform.lock.hclThe dependency lock file documentation describes what that buys: it verifies the official packages across all the given platforms and records both zh: and h1: checksums for each — the same discipline as pinning images by digest in a supply-chain-aware pipeline.
Interdependent Configurations: Migrate Bottom-Up
Nobody has one configuration directory. They have a network layer, a data layer, an identity layer and half a dozen application configurations reading each other through terraform_remote_state. Cutover order is not taste.
OpenTofu's guide for multiple configurations puts it in one line — "Migrate dependent configurations before their dependencies" — and works the failure through. In its example B, C and D read configuration A's state. Migrate A first and those three, still on Terraform, "would try to read state written by A (now on OpenTofu)", which could break if OpenTofu writes state in a format Terraform does not recognise. "Bottom-up migration avoids this risk entirely."
Read the graph the right way round. app consumes network, data and identity and nothing consumes app, so app is the leaf node and migrates first; the foundations it depends on migrate last. Invert that and you have left a Terraform configuration reading state OpenTofu wrote. Each step gets its own snapshot and plan diff — which also makes a half-finished migration a state you can sit in for a week, not a window you must close by Friday.
The Registry You Still Do Not Control
Here is where most write-ups stop and the sovereignty argument starts. You moved from a tool governed by one company to one governed by a foundation, and your provisioning still cannot run without a public service somebody else operates.
The OpenTofu Registry's own inclusion policy is direct about what it is: "The OpenTofu Registry is an index of providers and modules that work with OpenTofu. The providers and modules themselves are hosted by GitHub, not the OpenTofu Registry." Two dependencies, not one: an index and an artefact host.
The same document is equally direct about jurisdiction: the registry service is operated by OpenTofu, a Series of LF Projects, LLC, under the laws of the United States of America. The policy also excludes modules and providers produced by or in support of entities likely to be under embargo, or connected to countries under a technology embargo under US law.
None of this argues against OpenTofu. It argues that the licence was never the whole exposure: a migration ending at tofu plan changed the governing organisation without changing the dependency's shape.
Mirroring: Filesystem, Network and OCI
OpenTofu supports three mirror kinds; the choice is mostly what you already run. The simplest is a directory: tofu providers mirror creates the path structure expected for filesystem-based provider plugin mirrors, populated with .zip files containing the plugins. Copy that tree onto media, carry it across an air gap, and installation needs no network.
If you already operate an OCI registry for images, the second option costs almost nothing. OpenTofu 1.10.0 introduced OCI registries as a provider mirror kind and an oci: source scheme for module packages. An `oci_mirror` installation method takes a repository_template — an HCL-style template evaluating to an OCI repository address, interpolating hostname, namespace and type. It must interpolate any component include leaves wildcarded.
# ~/.tofurc, or tofurc in an XDG config directory.
provider_installation {
# Public-registry providers, from an OCI repository you operate.
oci_mirror {
repository_template = "registry.internal.example.com/opentofu-providers/${namespace}/${type}"
include = ["registry.opentofu.org/*/*"]
}
# In-house providers, from a tree written by `tofu providers mirror`.
filesystem_mirror {
path = "/srv/tofu/providers"
include = ["registry.internal.example.com/*/*"]
}
# Nothing reaches an origin registry.
direct {
exclude = ["*/*/*"]
}
}That last block is load-bearing. The CLI configuration documentation is explicit that to use a local mirror exclusively you must either remove the direct method altogether or use its exclude argument, and that where both include and exclude are set, the exclusions take priority. Leave direct open and a provider your mirror lacks falls through to the public registry — the day you discover that is the day it is unreachable. The same page notes OpenTofu reads .tofurc in preference to .terraformrc, so an existing Terraform config keeps working until you fork it.
Modules move the same way. A module package served from an OCI registry must be an OCI image manifest whose artifactType is application/vnd.opentofu.modulepkg, with a layers array holding exactly one descriptor whose mediaType is archive/zip. Any registry implementing the OCI distribution API and accepting a custom artifact type can serve that, so the registry you already run for images can distribute modules — confirm yours accepts the artifact type first.
Two limits. OpenTofu does not yet support an OCI registry as the *primary* installation source for a provider, only as a mirror — which is why the direct exclusion does the real work. And a provider existing only in your mirror needs lock entries made without an origin registry: `tofu providers lock` takes -fs-mirror or -net-mirror.
CI, and the Exit Ramp You Keep on Purpose
A mirror CI bypasses is decoration. Three things belong in the pipeline: a pinned, checksummed binary, a CLI config pointing at the mirror, and a lock file that cannot move without review.
name: infrastructure
on:
pull_request:
paths: ["infra/**"]
jobs:
plan:
runs-on: ubuntu-24.04
env:
# The mirrored provider_installation config, committed in the repository:
# CI never resolves a provider from a public registry.
TF_CLI_CONFIG_FILE: ${{ github.workspace }}/ci/tofurc
steps:
- uses: actions/checkout@v4
- uses: opentofu/setup-opentofu@v1
with:
tofu_version: 1.12.5
# Unset, the action falls back to the release SHA256SUMS file. This
# pins the binary to bytes you reviewed.
checksums: ${{ vars.TOFU_SHA256_LINUX_AMD64 }}
- name: init
working-directory: infra
run: tofu init -input=false
- name: lock file must not move
working-directory: infra
run: git diff --exit-code .terraform.lock.hcl
- name: plan
working-directory: infra
run: tofu plan -input=false -lock-timeout=120sThe checksums input is a newline-delimited list of SHA-256 hashes; set, the action verifies the binary matches one before proceeding, and unset it falls back to the release's SHA256SUMS file. The first is auditable. If the runners are compute you operate, bake the mirror and the pinned binary into the runner image and forbid egress to public registries — then the network enforces the gate, not an editable YAML file.
Now the part that keeps this reversible. OpenTofu has supported the `.tofu` extension since 1.8: when a file with that extension is present, OpenTofu ignores the identically named .tf, letting authors use OpenTofu-only features and keep compatible code. Used deliberately it is an exit ramp held open — one repository, two toolchains.
State encryption runs the other way. OpenTofu encrypts state and plan files at rest natively, configured in the terraform block with a key provider and a method — valuable when state holds credentials, complementary to keeping those credentials out of state in the first place, and the one feature that changes the shape of your data.
# Supplied as TF_VAR_state_passphrase from a secrets store — never a literal.
variable "state_passphrase" {
type = string
sensitive = true
}
terraform {
encryption {
key_provider "pbkdf2" "state" {
passphrase = var.state_passphrase
}
method "aes_gcm" "state" {
keys = key_provider.pbkdf2.state
}
# OpenTofu refuses to read unencrypted state by default, because it could
# have been manipulated. This permits the first read.
method "unencrypted" "migrate" {}
state {
method = method.aes_gcm.state
fallback {
method = method.unencrypted.migrate
}
# Remove the fallback once every writer has re-encrypted, then consider:
# enforced = true
}
}
}The Long Game: Pin the Toolchain You Cannot Afford to Lose
Strip the tool names out and a general rule is left. A relicensing is a governance event you cannot prevent, cannot predict and will not be consulted about. Forking is one answer, expensive and available only when a foundation and a maintainer community show up. The cheaper, more durable answer is to make the distribution path something you operate — so whoever owns the upstream, your builds keep running while you decide. Same argument as modelling the exit cost before you are forced to pay it.
- Mirror the distribution path, not just the artefact. A cached copy of today's provider is useful; a mirror answering every
tofu initis different. - Pin and checksum the binary in CI, storing the hash as reviewed configuration rather than trusting what the installer resolves.
- Regenerate multi-platform lock entries when the installation source changes, gating on
git diff --exit-code. - Keep the exit ramp exercised. The
.tofuextension and a plan-diff harness answer "could we go back?" with evidence. - Re-read the governance documents annually. Licences, inclusion policies and jurisdictions change quietly.
The test is the one we apply to any dependency: cut the internet and rebuild. If tofu init still resolves every provider and module from infrastructure you operate, the migration bought something durable. If not, you changed which organisation governs your toolchain — worth doing, and smaller than it looked. The registry is the hard part, and the part that stays solved.
§FAQ/Common questions
Frequently asked
Does the Terraform BSL licence actually forbid using Terraform for my own infrastructure?
No. Terraform's LICENSE file grants production use provided your use does not include offering the Licensed Work to third parties on a hosted or embedded basis in order to compete with IBM's paid versions of it, and the Additional Use Grant states explicitly that hosting or using the work for internal purposes within an organisation is not considered a competitive offering. If you run Terraform to build the infrastructure your own company operates, the grant covers that. The parameters also set a Change Date of four years from each version's publication and a Change License of MPL 2.0, so every BSL version converts on a schedule. This is a reading of the licence text and not legal advice; genuine edge cases, such as whether a particular managed offering competes, belong with counsel. The sound reason to evaluate OpenTofu is governance — who can change the terms next time — rather than present-day compliance.
Why does tofu init change my provider versions when Terraform's lock file pinned them?
Because the automatic lock-file translation added in OpenTofu 1.10.0 is deliberately scoped. The changelog says it applies only to providers rebuilt from source and republished on the OpenTofu Registry by the OpenTofu project, because equivalence cannot be assumed for third-party providers published in other namespaces. So a lock file that migrates cleanly for hashicorp/aws can move a version for a vendor provider in its own namespace — which is exactly what issue #2775 reported, accepted as a bug and closed as completed on 19 May 2025. The safe procedure is to read the versions out of the pre-migration lock file, write them into your version constraints as exact pins for every non-hashicorp provider, and gate CI on git diff --exit-code against the lock file so any future movement fails the build rather than shipping silently.
Does OpenTofu rewrite the provider addresses stored in my state file?
Not by planning. The function in internal/tofumigrate/tofumigrate.go updates the in-memory view of the state to use registry.opentofu.org addresses, and it applies only to providers not explicitly referenced in the configuration in full form. If a required_providers block pins source = "registry.terraform.io/hashicorp/random", the source comment states the old address is kept. Setting OPENTOFU_STATEFILE_PROVIDER_ADDRESS_TRANSLATION to 0 disables the behaviour entirely, which is useful for reproducing a failure and risky to leave set in CI. A plan leaves the file alone, but the first apply will typically write the translated addresses back, so this is a rewrite that happens by default rather than one you opt into. To make it deliberate, run tofu state replace-provider; its documentation notes that it outputs a backup copy of the state before saving changes and that the backup cannot be disabled, given the destructive nature of the command.
Can I run OpenTofu fully air-gapped, with no public registry access?
Yes, for providers and modules both, using mirrors. tofu providers mirror writes the directory structure expected for a filesystem-based provider mirror, populated with .zip plugin packages, which you can carry across an air gap on removable media. Alternatively an oci_mirror installation method redirects installation to an OCI repository you operate, addressed by a repository_template that interpolates the provider's hostname, namespace and type. Modules can be served from the same registry using the oci: source scheme, provided the artifact is an OCI image manifest with artifactType application/vnd.opentofu.modulepkg and exactly one archive/zip layer. Two constraints matter: OpenTofu does not yet support an OCI registry as the primary installation source for a provider, only as a mirror; and you must add a direct block excluding everything, because exclude patterns take priority over include for a given method.
How do I keep the option of returning to Terraform after migrating?
Two mechanisms, pulling in opposite directions. Since OpenTofu 1.8 a file with the .tofu extension causes OpenTofu to ignore the identically named .tf file, which is the supported way to keep one repository working under both toolchains: OpenTofu-only features live in .tofu files and the .tf equivalents stay valid. Keep a plan-diff harness alongside it so "could we go back?" is answered with evidence. Pulling the other way, OpenTofu's native state and plan encryption changes the shape of the data at rest, so adopting it makes the return path a deliberate decrypt step rather than a no-op. That is documented and reversible — add an unencrypted method, move the original method into a fallback block, apply, then remove the state block — but it is a step you should plan for rather than discover.
Further reading
- Own the Registry: Harbor and Zot for air-gapped images
- Own your pipeline: Forgejo, Woodpecker and Zot off GitHub Actions
- OSS supply chain security: SBOM, Sigstore and admission control
- GitHub self-hosted runners on Kubernetes: owning CI compute with ARC
- Kubernetes secrets management is still broken: ESO over Vault
- Digital sovereignty: from policy slogan to testable architecture
- After MinIO goes dark: Rook/Ceph, SeaweedFS or Garage for sovereign S3
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.