Last month I finally pulled the trigger. After months of watching the OpenTofu project mature and HashiCorp’s licensing situation settle into something I wasn’t comfortable with for client work, I migrated 47 Terraform modules across three production environments to OpenTofu. It took about two weeks of actual work spread over a month, and most of it was smooth. Most.

Why I Switched

The BSL license change was the catalyst, but not the only reason. A few of my clients started asking uncomfortable questions about their Terraform Enterprise contracts. One of them got a letter from HashiCorp’s sales team that made the cost trajectory pretty clear. OpenTofu had reached a point where the risk of staying felt bigger than the risk of moving.

I want to be clear: Terraform is still a good tool. This isn’t a hate post. It’s a “here’s what actually happens when you migrate” post.

The Easy Part: Basic Modules

For straightforward modules (VPCs, security groups, S3 buckets, IAM), the migration was literally:

# Install OpenTofu
brew install opentofu

# In your module directory
tofu init
tofu plan

That’s it. No state migration needed. OpenTofu reads Terraform state files natively. For about 30 of my 47 modules, this was the entire process. Run tofu init, see a clean plan, move on.

The only change I made was updating CI pipelines to use the opentofu/setup-opentofu GitHub Action instead of hashicorp/setup-terraform:

- name: Setup OpenTofu
  uses: opentofu/setup-opentofu@v1
  with:
    tofu_version: "1.9.0"

- name: Init
  run: tofu init

- name: Plan
  run: tofu plan -no-color

The Medium Part: Provider Pinning

About 10 modules had provider version constraints that needed attention. Not because OpenTofu can’t use the same providers, it can and does. But because some of my .terraform.lock.hcl files had platform-specific hashes that needed regenerating.

# Remove old lock file and regenerate
rm .terraform.lock.hcl
tofu init
tofu providers lock \
  -platform=linux_amd64 \
  -platform=darwin_arm64

One gotcha: if you use required_providers blocks with source attributes pointing to registry.terraform.io, they still work. OpenTofu’s registry mirrors the official one. But I updated them anyway for clarity:

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

This block works identically in both tools. No changes needed.

The Hard Part: State Encryption

This is where OpenTofu gets interesting, and where I hit my first real problem. OpenTofu supports state encryption natively, something Terraform never offered without Enterprise. I wanted to enable it.

The configuration looks like this:

terraform {
  encryption {
    method "aes_gcm" "main" {
      keys = key_provider.aws_kms.main
    }

    key_provider "aws_kms" "main" {
      kms_key_id = "arn:aws:kms:eu-central-1:123456789:key/abc-def-123"
      region     = "eu-central-1"
    }

    state {
      method = method.aes_gcm.main
    }
  }
}

The problem: once you encrypt state, you can’t go back to Terraform. This is a one-way door. I learned this the hard way in staging when I encrypted state, then realized one of my colleagues still had terraform aliased in their shell. Their next terraform plan blew up with a state parsing error that looked like corruption.

My advice: don’t enable state encryption until every single person and pipeline touching that state has switched to tofu. Add a wrapper script or Makefile that enforces it:

#!/bin/bash
# tf-wrapper.sh
if command -v tofu &> /dev/null; then
  tofu "$@"
else
  echo "ERROR: Install OpenTofu first. See https://opentofu.org/docs/intro/install/"
  exit 1
fi

The Ugly Part: Custom Providers

I maintain two internal providers for client-specific APIs. These were built with the Terraform Plugin SDK, and they work fine with OpenTofu. But the build and release pipeline referenced registry.terraform.io in the provider address, and our internal registry (a simple S3 bucket with a specific directory structure) needed its metadata files updated.

The fix was updating the terraform-registry-manifest.json:

{
  "version": 1,
  "metadata": {
    "protocol_versions": ["6.0"]
  }
}

And making sure the network mirror configuration in .terraformrc (yes, OpenTofu still reads this file, or you can use .tofurc) pointed to the right place:

provider_installation {
  network_mirror {
    url = "https://our-internal-registry.example.com/providers/"
  }
  direct {
    exclude = ["example.com/*/*"]
  }
}

Testing Strategy

I didn’t just yolo this into production. Here’s the process I followed for each module:

  1. Run terraform plan and save the output
  2. Run tofu plan and save the output
  3. Diff the two outputs
  4. If identical, proceed. If not, investigate.
terraform plan -no-color > /tmp/tf-plan.txt 2>&1
tofu plan -no-color > /tmp/tofu-plan.txt 2>&1

# Strip version headers and diff
diff <(tail -n +5 /tmp/tf-plan.txt) <(tail -n +5 /tmp/tofu-plan.txt)

For 44 out of 47 modules, the diff was empty. The three that differed had minor formatting differences in the plan output, not actual resource changes.

CI/CD Migration Checklist

Here’s what I updated across our pipelines:

  • GitHub Actions: swapped hashicorp/setup-terraform for opentofu/setup-opentofu
  • Pre-commit hooks: updated terraform_fmt to tofu fmt (the pre-commit-terraform hooks support both now)
  • Atlantis: switched to the OpenTofu-compatible fork. This was the most annoying part because Atlantis config is spread across atlantis.yaml, server-side config, and repo-level overrides
  • Documentation: find-and-replace terraform with tofu in all READMEs (but kept references to “Terraform” as the concept/language)

What I’d Do Differently

Start with non-production modules. I did this, but I should have run both tools in parallel for longer. Two weeks wasn’t enough to catch the state encryption gotcha before it bit someone.

Communicate more. I sent a Slack message and updated the wiki. I should have done a 15-minute walkthrough with the team. The person who ran terraform plan on encrypted state could have been avoided with a quick demo.

Don’t encrypt state immediately. Get everyone migrated first. State encryption is a great feature, but it’s the one thing that makes the migration irreversible. Keep that door open for at least a month.

Performance

One thing I noticed: tofu init is noticeably faster than terraform init for modules with many providers. I didn’t benchmark it scientifically, but the module with 6 providers went from about 12 seconds to about 7 seconds. Not life-changing, but nice.

tofu plan and tofu apply felt identical in speed to their Terraform counterparts.

Should You Migrate?

If you’re on Terraform Community Edition and the license situation bothers you, or if your clients ask about it, yes. The migration is genuinely low-risk for most setups.

If you’re on Terraform Enterprise or Cloud, the calculation is different. You’d need to replace that functionality (remote state management, policy as code, drift detection) with other tools. That’s a bigger project.

If everything is working fine and nobody cares about licensing, there’s no rush. Both tools read the same state format, use the same providers, and accept the same HCL. You can always switch later.

The important thing is that the option exists, it works, and it’s production-ready. I’m running it across real infrastructure serving real traffic, and sleeping fine.