DEV Community

Mateen Anjum
Mateen Anjum

Posted on

I Migrated a Real Production Codebase from Terraform to OpenTofu (Here's What Broke)

TL;DR: Migrating a standard AWS Terraform codebase to OpenTofu took half a day, most of which was CI pipeline updates. The S3 native locking alone made it worth it.


The Problem

I've been writing Terraform since version 0.8. Watched it grow from a scrappy infrastructure tool into the de-facto standard for cloud automation. I've migrated teams from CloudFormation to Terraform, written custom providers, debugged state corruption at 2 AM. Terraform is baked into how I think about infrastructure.

So when HashiCorp switched to the Business Source License in August 2023, I did what most practitioners did: I shrugged, bookmarked the OpenTofu repo, and went back to building.

That bookmark sat there for two years.

The BSL doesn't prevent you from using Terraform. It prevents you from building a product or service that's "substantially similar" to Terraform Cloud or Terraform Enterprise. For most teams running internal infrastructure, the risk is low. But once you're building a platform team that exposes self-service infrastructure to internal customers, or packaging IaC automation as part of a managed service, your legal team might want a conversation. And once "get legal sign-off on our IaC toolchain" is on the agenda, you've already lost an afternoon you'll never get back.

For a Phono Technologies project, we were building a lightweight CI/CD orchestration layer for client infrastructure. The moment I tried to describe it, I realized I was describing exactly what the BSL restricts. The ambiguity was real enough that I wanted it gone.


What I Tried First (And Why It Failed)

My first instinct was to just drop in the tofu binary and run tofu init. Simple enough.

It almost worked. Until I checked where providers were being pulled from.

OpenTofu fetches providers from registry.opentofu.org, not registry.terraform.io. The registries mirror each other for HashiCorp providers, but your existing .terraform.lock.hcl was generated against Terraform's registry. The provider hashes don't match.

Error: Failed to install provider

To install this provider, OpenTofu needs to verify that the checksums in
.terraform.lock.hcl match the provider packages downloaded from the registry.
The following packages are required but the checksums don't match:
  registry.opentofu.org/hashicorp/aws v5.82.0
Enter fullscreen mode Exit fullscreen mode

I also ran into teammates who still had the old Terraform-generated lock files. Some ran tofu plan on their local branches and got hash mismatches in the other direction. The lesson: this has to be a coordinated team migration, not a quiet swap on your own laptop.


The Solution

The codebase: a mid-sized AWS platform for a SaaS client. Around 8,000 lines of Terraform across 12 modules. Standard providers: aws, kubernetes, helm, random, tls. S3 backend for state, one workspace per environment. CI via GitHub Actions. No Terraform Cloud, no HCP.

Step 1: Back up everything

Before touching anything, tag the current state in git and pull a snapshot of your state file:

git tag pre-opentofu-migration

terraform state pull > terraform.tfstate.backup-$(date +%Y%m%d)
Enter fullscreen mode Exit fullscreen mode

If you're on S3, enable versioning before you start. You want a timestamped rollback point. Non-negotiable.

Step 2: Install tofu alongside terraform

The two binaries coexist without conflict:

brew install opentofu
tofu --version
# OpenTofu v1.11.4
# on darwin_arm64
Enter fullscreen mode Exit fullscreen mode

Keep terraform installed until you're confident the migration is complete.

Step 3: Delete the lock file and re-init

rm .terraform.lock.hcl
tofu init
Enter fullscreen mode Exit fullscreen mode

tofu init regenerates the lock file with hashes for both registry.opentofu.org and registry.terraform.io providers, signed by OpenTofu's key infrastructure. Commit the new lock file and announce to your team to re-run tofu init on their local copies.

Once you commit the new lock file, treat the repo as an OpenTofu project. Don't run terraform init on the same directory afterward. The two binaries will fight over hashes.

Step 4: Check your terraform {} block

You don't have to rename it. OpenTofu still accepts the terraform {} block. Your existing HCL works without modification.

# This works fine in OpenTofu, no changes needed
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-locks"
  }
}
Enter fullscreen mode Exit fullscreen mode

You can leave it as terraform {} or rename it to tofu {}. Both work.

Step 5: Verify with tofu plan

tofu plan -out=migration-test.tfplan
Enter fullscreen mode Exit fullscreen mode

Expected result: no changes. If you see changes, do not apply. Investigate first. It usually means a provider version difference or a schema update.

I got zero changes across all three environments.

Step 6: Drop DynamoDB for S3 native locking

This is where OpenTofu pulls ahead. OpenTofu 1.10.0 added native conditional writes for S3 state locking. No DynamoDB table required.

Before:

backend "s3" {
  bucket         = "my-state-bucket"
  key            = "prod/terraform.tfstate"
  region         = "us-east-1"
  encrypt        = true
  dynamodb_table = "terraform-locks"
}
Enter fullscreen mode Exit fullscreen mode

After:

backend "s3" {
  bucket       = "my-state-bucket"
  key          = "prod/terraform.tfstate"
  region       = "us-east-1"
  encrypt      = true
  use_lockfile = true
}
Enter fullscreen mode Exit fullscreen mode

Fewer moving parts. One less AWS service to manage. Simpler IAM permissions.

Step 7: Update your CI pipeline

Every place your pipeline runs terraform, you need tofu. In GitHub Actions:

Before:

- uses: hashicorp/setup-terraform@v3
  with:
    terraform_version: "1.9.5"
Enter fullscreen mode Exit fullscreen mode

After:

- uses: opentofu/setup-opentofu@v1
  with:
    tofu_version: "1.11.4"
Enter fullscreen mode Exit fullscreen mode

The opentofu/setup-opentofu action is the official GitHub Action. Clean swap.


Results

Metric Before After
State locking dependencies S3 + DynamoDB S3 only
DynamoDB tables 3 (one per environment) 0
Migration time N/A 4 hours (including CI updates)
Plan output differences N/A None
Sensitive values in state Persisted Ephemeral (with 1.11 features)

The operational simplicity of dropping DynamoDB is hard to quantify in a table. It's one less service in IAM policies, one less resource to manage in the state backend module, one less thing that can drift or get misconfigured.


Lessons Learned

  1. Coordinate the lock file migration as a team. If half your team is still running terraform init, you'll get hash conflicts. Announce the cutover date, have everyone delete and regenerate their lock files on the same day.

  2. Pin your OpenTofu version in CI. The 1.11.x patch cycle had a notable regression in 1.11.0 that was fixed in 1.11.2. The team moves fast. Pin to a specific minor version in CI and upgrade deliberately.

  3. The terraform {} block is fine. Don't waste time renaming it. The binary changed; the HCL didn't.

  4. The point of no return is tofu apply. After you run apply, the state metadata reflects OpenTofu's version. You can still read the state with Terraform, but you'll get warnings. Decide before you apply whether you're committed.

  5. Ephemeral values are worth understanding. OpenTofu 1.11.0 introduced ephemeral resources and write-only attributes. Sensitive credentials can be used without ever landing in state. If you've been papering over this with Vault workarounds, it's worth reading the docs before you finish the migration.

ephemeral "aws_secretsmanager_secret_version" "db_password" {
  secret_id = aws_secretsmanager_secret.db.id
}

resource "kubernetes_secret_v1" "db_credentials" {
  metadata {
    name      = "db-credentials"
    namespace = "app"
  }

  data_wo = {
    password = ephemeral.aws_secretsmanager_secret_version.db_password.secret_string
  }

  data_wo_revision = 1
}
Enter fullscreen mode Exit fullscreen mode

Try It Yourself

OpenTofu Migration Guide: opentofu.org/docs/intro/migration

Top comments (0)