This is a guest post on Spacelift’s blog, dated 19 September 2025. Matt Velez is a site reliability engineer at TrueCar, and TrueCar is a Spacelift customer. Spacelift publishes the page. The migration detail is his.
TrueCar’s SRE team runs a large AWS estate through Spacelift. They started on Spacelift in August 2022. In August 2023 HashiCorp moved Terraform to the Business Source License, which he describes as closed source. Staying on one Terraform version forever was not a plan they would keep. Spacelift had already lined up OpenTofu, and Matt says Spacelift’s role in the fork weighed in the decision.
They had over 700 stacks to move. The Terraform → OpenTofu playbook is the generic state and provider path. TrueCar’s extra constraint is Spacelift, a monorepo, and a backend block written so terraform init cannot succeed.
The version ladder
Matt says OpenTofu’s migration docs specified the order they used:
- Get every module onto Terraform 1.5.5.
- Move to OpenTofu 1.6.2.
- Move to the latest OpenTofu they were targeting, 1.9.0 at the time.
The overview page I opened no longer spells out 1.5.5, 1.6.2, and 1.9.0. It says to back up state, install OpenTofu, init, and test a small change, then sends you to a longer guide. Treat the three-step ladder as TrueCar’s record of the docs they followed, and re-read the current guide before you copy the version numbers.
Some stacks were still on Terraform versions as early as 0.11. The work was months, and dozens of pull requests. Code lives in a monorepo. He points at an earlier TrueCar case study for that layout. I did not use that page for the migration facts below.
What got in the way
Most stacks were managed outside the monorepo. A change needed two pull requests, one in the Terraform repo and one in the monolith to point at the new commit. They moved the code into the monorepo so a change was one pull request.
They had to run several Terraform and OpenTofu versions on one machine. Matt uses mise for that.
A pull request full of OpenTofu 1.9.0 code could still have been planned with Terraform 1.5.5 or older inside Spacelift. He says runtime configuration stopped that. I am not repeating Spacelift’s settings page. He says it was enough.
They also needed Terraform to fail if someone pointed it at an OpenTofu state. That became the backend trick below.
The migrations ran from a script over the 700 stacks. By default it only plans. You pass apply to change anything. It looks for directories with a state.tf, deletes .terraform, inits 1.6.2, then inits 1.9.0 with -reconfigure. If init fails, or the plan is not empty, it skips that stack and records it. On apply it swaps state.tf for a symlink to a shared .state.tf.base and may append override variables to terraform.tfvars. He warns that the script hides OpenTofu’s own output, hardcodes a directory offset from his laptop, and does not set the Spacelift runtime. The full script is in his post. I am not pasting it here.
A backend Terraform will not init
Remote state is encrypted in S3. The old backend blocks were copies, and the only field that changed was key, something like nginx/prod.tfstate. People forgot to edit the key. He calls that a minor annoyance they had already lived with.
OpenTofu 1.8 can evaluate variables and locals early enough to use them in a backend, as long as they do not depend on resources, data sources, or module outputs. The 1.8 note says tofu init reads .tfvars and -var for that. Matt used it so the key is derived from the directory:
key = "${local.state_repo}/${local.state_name}.tfstate"
Stacks sit one to three directories deep. A local named repo_index counts how far .state.tf.base is, then the reversed working directory supplies the repo name and the state name. Odd paths set state_repo_override or state_name_override. The file is symlinked into each stack.
terraform init then errors with “Variables not allowed” on that key. That is the guard. Someone running Terraform cannot quietly rewrite the OpenTofu state. He also likes provider for_each and the richer moved and removed blocks, and says they have not adopted those yet.
Inside Spacelift, support for OpenTofu goes back to 1.6, and switching a stack is a setting. They manage stacks with the Spacelift provider, so the permanent switch was:
terraform_workflow_tool = "OPEN_TOFU"
What I would copy
Pin the ladder in the pull request, including the Terraform version you refuse to run. TrueCar’s was 1.5.5, then OpenTofu 1.6.2, then 1.9.0. The current OpenTofu overview may list different steps.
Make a plan with no changes the bar for “this stack moved.” Their script skips anything that wants to apply a diff.
Put the OpenTofu-only syntax in the backend on purpose if you need a tripwire. Early locals in the S3 key are enough for terraform init to fail. Read the 1.8 limits before you reference a data source from that local.
If Spacelift (or any runner) picks the binary, set it in the same change as the code. A 1.9.0 pull request planned with Terraform 1.5.5 was the failure they were avoiding.