Local time :

9:58 AM

Infrastructure as Code

Terraform or OpenTofu? The no-BS guide for teams making the switch

Two years after the Terraform license change, the dust has settled. Here's the practical breakdown — features, migration steps, gotchas, and a decision framework to pick the right IaC tool for your team.

It's been two years since HashiCorp changed Terraform's license from MPL 2.0 to BSL 1.1, and the dust has finally settled. OpenTofu — the community-driven fork under the Linux Foundation — has matured significantly. Terraform, meanwhile, has doubled down on its enterprise ecosystem under the IBM umbrella after the acquisition.

If you're a DevOps engineer or platform team lead evaluating your IaC strategy right now, you're probably asking the same question everyone else is: should I migrate to OpenTofu, stay with Terraform, or hedge my bets?

We've helped multiple teams navigate this decision at SeedStack. This isn't a hype piece. It's the honest, practical breakdown we wish someone had given us two years ago — complete with real migration steps, gotchas, and a decision framework you can actually use.

The State of the Fork: Where Things Stand Today

When OpenTofu launched in September 2023, skeptics (including us) questioned whether a fork could keep pace with HashiCorp's engineering resources. Two years later, the numbers tell a different story.

OpenTofu has crossed 10 million downloads. Fidelity Investments — one of the world's largest financial services firms — publicly shared their enterprise-scale migration. The project has attracted contributors from major cloud providers and infrastructure companies, and the release cadence has been consistent.

Terraform, on the other hand, still commands roughly 62% of the IaC market by usage. It has the ecosystem depth, the massive provider registry, and years of battle-tested production deployments backing it up. IBM's acquisition of HashiCorp added enterprise muscle but also raised new questions about long-term direction.

Neither tool is going away. The question is which one fits your team better.

Feature-by-Feature: What Actually Matters

Let's skip the marketing and talk about the features that impact your day-to-day workflow.

State Encryption

This is OpenTofu's standout win. Client-side state encryption is built natively into OpenTofu — your state file is encrypted before it ever leaves your machine, regardless of which backend you use.

With Terraform, state encryption depends entirely on your backend. Using S3? You need to configure SSE-KMS separately. Using Terraform Cloud? HashiCorp handles it, but you're trusting their infrastructure. For teams in regulated industries (healthcare, finance, government), OpenTofu's approach is objectively simpler and more auditable.

# OpenTofu - native state encryption
terraform {
  encryption {
    key_provider "aws_kms" "state_key" {
      kms_key_id = "alias/opentofu-state"
      region     = "us-east-1"
    }

    method "aes_gcm" "encrypt" {
      keys = key_provider.aws_kms.state_key
    }

    state {
      method   = method.aes_gcm.encrypt
      enforced = true
    }

    plan {
      method   = method.aes_gcm.encrypt
      enforced = true
    }
  }
}
# OpenTofu - native state encryption
terraform {
  encryption {
    key_provider "aws_kms" "state_key" {
      kms_key_id = "alias/opentofu-state"
      region     = "us-east-1"
    }

    method "aes_gcm" "encrypt" {
      keys = key_provider.aws_kms.state_key
    }

    state {
      method   = method.aes_gcm.encrypt
      enforced = true
    }

    plan {
      method   = method.aes_gcm.encrypt
      enforced = true
    }
  }
}
# OpenTofu - native state encryption
terraform {
  encryption {
    key_provider "aws_kms" "state_key" {
      kms_key_id = "alias/opentofu-state"
      region     = "us-east-1"
    }

    method "aes_gcm" "encrypt" {
      keys = key_provider.aws_kms.state_key
    }

    state {
      method   = method.aes_gcm.encrypt
      enforced = true
    }

    plan {
      method   = method.aes_gcm.encrypt
      enforced = true
    }
  }
}

With Terraform, the equivalent requires backend-specific configuration and often a wrapper script or CI/CD step to ensure encryption is never bypassed.

Provider for_each

Both tools now support for_each on providers, but OpenTofu shipped it earlier and the implementation is more flexible for multi-region and multi-account deployments.

# Deploy the same module across multiple AWS regions
variable "regions" {
  default = ["us-east-1", "eu-west-1", "ap-southeast-1"]
}

provider "aws" {
  for_each = toset(var.regions)
  alias    = each.value
  region   = each.value
}

module "regional_infra" {
  for_each = toset(var.regions)
  source   = "./modules/regional"

  providers = {
    aws = aws[each.value]
  }
}
# Deploy the same module across multiple AWS regions
variable "regions" {
  default = ["us-east-1", "eu-west-1", "ap-southeast-1"]
}

provider "aws" {
  for_each = toset(var.regions)
  alias    = each.value
  region   = each.value
}

module "regional_infra" {
  for_each = toset(var.regions)
  source   = "./modules/regional"

  providers = {
    aws = aws[each.value]
  }
}
# Deploy the same module across multiple AWS regions
variable "regions" {
  default = ["us-east-1", "eu-west-1", "ap-southeast-1"]
}

provider "aws" {
  for_each = toset(var.regions)
  alias    = each.value
  region   = each.value
}

module "regional_infra" {
  for_each = toset(var.regions)
  source   = "./modules/regional"

  providers = {
    aws = aws[each.value]
  }
}

This pattern is a game-changer for teams managing multi-region infrastructure. Instead of copy-pasting provider blocks or using complex wrapper modules, you can express multi-region deployments declaratively.

Registry Compatibility

Terraform's provider registry is massive — over 4,000 providers and 15,000+ modules. OpenTofu maintains its own registry that mirrors most of the Terraform ecosystem, but there are edge cases.

Most mainstream providers (AWS, GCP, Azure, Kubernetes, Cloudflare, Datadog) work identically on both. Where you might hit friction is with niche or enterprise-specific providers that are tightly coupled to Terraform Cloud features. If your team relies on Sentinel policies or Terraform Cloud's run tasks, those are Terraform-only.

Performance

In our benchmarks across three production codebases (ranging from 200 to 2,000 resources), plan and apply times were within 5% of each other. Neither tool has a meaningful performance advantage for typical workloads.

Where we did see a difference was in state operations on large state files (10,000+ resources). OpenTofu's state handling showed marginal improvements in read/write operations, likely due to optimizations in the fork's state serialization code. But for most teams, this won't be the deciding factor.

The Migration Playbook: Step by Step

If you've decided to move, here's the process we follow at SeedStack. It's designed to be low-risk and reversible at every stage.

Step 1: Audit Your Current Setup

Before touching any code, catalog what you're working with.

# List all providers and their versions
terraform providers

# Check for any Terraform Cloud/Enterprise features in use
grep -r "cloud {" *.tf
grep -r "remote_exec" *.tf

# Count resources across all state files
terraform state list | wc -l

# Check for any BSL-licensed provider dependencies
terraform version -json | jq '.provider_selections'
# List all providers and their versions
terraform providers

# Check for any Terraform Cloud/Enterprise features in use
grep -r "cloud {" *.tf
grep -r "remote_exec" *.tf

# Count resources across all state files
terraform state list | wc -l

# Check for any BSL-licensed provider dependencies
terraform version -json | jq '.provider_selections'
# List all providers and their versions
terraform providers

# Check for any Terraform Cloud/Enterprise features in use
grep -r "cloud {" *.tf
grep -r "remote_exec" *.tf

# Count resources across all state files
terraform state list | wc -l

# Check for any BSL-licensed provider dependencies
terraform version -json | jq '.provider_selections'

The audit should answer three questions: (1) Are you using any Terraform Cloud-specific features? (2) Do any of your providers have licensing restrictions? (3) How large and complex is your state?

Step 2: Set Up OpenTofu in Parallel

Don't rip and replace. Install OpenTofu alongside Terraform and run both against a non-production workspace first.

# Install OpenTofu (macOS)
brew install opentofu

# Or via the install script (Linux)
curl -fsSL https://get.opentofu.org/install-opentofu.sh | sh

# Verify installation
tofu version

# Initialize in an existing Terraform directory
# OpenTofu reads the same .tf files and state format

# Install OpenTofu (macOS)
brew install opentofu

# Or via the install script (Linux)
curl -fsSL https://get.opentofu.org/install-opentofu.sh | sh

# Verify installation
tofu version

# Initialize in an existing Terraform directory
# OpenTofu reads the same .tf files and state format

# Install OpenTofu (macOS)
brew install opentofu

# Or via the install script (Linux)
curl -fsSL https://get.opentofu.org/install-opentofu.sh | sh

# Verify installation
tofu version

# Initialize in an existing Terraform directory
# OpenTofu reads the same .tf files and state format

OpenTofu reads the same .tf configuration files, the same .tfstate format, and uses the same provider protocol. In most cases, tofu init just works.

Step 3: Validate with a Plan Diff

Run plans from both tools against the same state and compare.

# Generate plans from both tools
terraform plan -out=tf.plan 2>&1 | tee tf-plan-output.txt
tofu plan -out=tofu.plan 2>&1 | tee tofu-plan-output.txt

# Compare the outputs
diff

# Generate plans from both tools
terraform plan -out=tf.plan 2>&1 | tee tf-plan-output.txt
tofu plan -out=tofu.plan 2>&1 | tee tofu-plan-output.txt

# Compare the outputs
diff

# Generate plans from both tools
terraform plan -out=tf.plan 2>&1 | tee tf-plan-output.txt
tofu plan -out=tofu.plan 2>&1 | tee tofu-plan-output.txt

# Compare the outputs
diff

For clean codebases, you should see zero diff. If there are differences, they'll typically be in provider version resolution or deprecated syntax handling. Document every difference before proceeding.

Step 4: Update CI/CD Pipelines

This is where the real work happens. Replace terraform commands with tofu in your pipeline configurations.

# GitHub Actions example - before
- name: Terraform Plan
  run: terraform plan -out=plan.tfplan

# GitHub Actions example - after
- name: OpenTofu Plan
  uses: opentofu/setup-opentofu@v1
  with:
    tofu_version: "1.9.x"
- run

# GitHub Actions example - before
- name: Terraform Plan
  run: terraform plan -out=plan.tfplan

# GitHub Actions example - after
- name: OpenTofu Plan
  uses: opentofu/setup-opentofu@v1
  with:
    tofu_version: "1.9.x"
- run

# GitHub Actions example - before
- name: Terraform Plan
  run: terraform plan -out=plan.tfplan

# GitHub Actions example - after
- name: OpenTofu Plan
  uses: opentofu/setup-opentofu@v1
  with:
    tofu_version: "1.9.x"
- run

# GitLab CI example
stages:
  - validate
  - plan
  - apply

.tofu_base:
  image: ghcr.io/opentofu/opentofu:1.9
  before_script:
    - tofu init -backend-config="config/${CI_ENVIRONMENT_NAME}.hcl"

plan:
  extends: .tofu_base
  stage: plan
  script:
    - tofu plan -out=plan.tfplan
  artifacts:
    paths:
      - plan.tfplan

apply:
  extends: .tofu_base
  stage: apply
  script:
    - tofu apply plan.tfplan
  when: manual
  only

# GitLab CI example
stages:
  - validate
  - plan
  - apply

.tofu_base:
  image: ghcr.io/opentofu/opentofu:1.9
  before_script:
    - tofu init -backend-config="config/${CI_ENVIRONMENT_NAME}.hcl"

plan:
  extends: .tofu_base
  stage: plan
  script:
    - tofu plan -out=plan.tfplan
  artifacts:
    paths:
      - plan.tfplan

apply:
  extends: .tofu_base
  stage: apply
  script:
    - tofu apply plan.tfplan
  when: manual
  only

# GitLab CI example
stages:
  - validate
  - plan
  - apply

.tofu_base:
  image: ghcr.io/opentofu/opentofu:1.9
  before_script:
    - tofu init -backend-config="config/${CI_ENVIRONMENT_NAME}.hcl"

plan:
  extends: .tofu_base
  stage: plan
  script:
    - tofu plan -out=plan.tfplan
  artifacts:
    paths:
      - plan.tfplan

apply:
  extends: .tofu_base
  stage: apply
  script:
    - tofu apply plan.tfplan
  when: manual
  only

Step 5: Enable State Encryption (Optional but Recommended)

If state encryption was part of your migration motivation, enable it after confirming everything works.

# Add to your backend configuration
terraform {
  encryption {
    key_provider "aws_kms" "main" {
      kms_key_id = "alias/tofu-state-key"
      region     = "us-east-1"
    }

    method "aes_gcm" "default" {
      keys = key_provider.aws_kms.main
    }

    state {
      method   = method.aes_gcm.default
      enforced = true
    }
  }
}
# Add to your backend configuration
terraform {
  encryption {
    key_provider "aws_kms" "main" {
      kms_key_id = "alias/tofu-state-key"
      region     = "us-east-1"
    }

    method "aes_gcm" "default" {
      keys = key_provider.aws_kms.main
    }

    state {
      method   = method.aes_gcm.default
      enforced = true
    }
  }
}
# Add to your backend configuration
terraform {
  encryption {
    key_provider "aws_kms" "main" {
      kms_key_id = "alias/tofu-state-key"
      region     = "us-east-1"
    }

    method "aes_gcm" "default" {
      keys = key_provider.aws_kms.main
    }

    state {
      method   = method.aes_gcm.default
      enforced = true
    }
  }
}

Run tofu init again after adding encryption. OpenTofu will encrypt the state file in place on the next tofu apply.

The Gotchas Nobody Tells You About

1. Module Registry URLs

If your modules reference registry.terraform.io explicitly, you'll need to update them to registry.opentofu.org — or better yet, use generic source paths.

# This works on both tools
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}

# This is Terraform-specific and will break on OpenTofu
module "vpc" {
  source = "app.terraform.io/my-org/vpc/aws"
}
# This works on both tools
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}

# This is Terraform-specific and will break on OpenTofu
module "vpc" {
  source = "app.terraform.io/my-org/vpc/aws"
}
# This works on both tools
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}

# This is Terraform-specific and will break on OpenTofu
module "vpc" {
  source = "app.terraform.io/my-org/vpc/aws"
}

2. Terraform Cloud Workspace Migration

If you're on Terraform Cloud, migrating state out requires careful planning. You need to pull state, configure a new backend (S3, GCS, Azure Blob, or pg), and push state to the new location. Never do this on a Friday.

# Pull current state from Terraform Cloud
terraform state pull > current.tfstate

# Reconfigure backend to S3
tofu init -migrate-state
# Pull current state from Terraform Cloud
terraform state pull > current.tfstate

# Reconfigure backend to S3
tofu init -migrate-state
# Pull current state from Terraform Cloud
terraform state pull > current.tfstate

# Reconfigure backend to S3
tofu init -migrate-state

3. Sentinel Policies Don't Exist in OpenTofu

If your organization relies on HashiCorp Sentinel for policy enforcement, you'll need to replace it with Open Policy Agent (OPA) or Checkov. This is often the largest effort in the migration — not because OPA is harder, but because Sentinel policies need to be rewritten from scratch.

# Example: Replace Sentinel with OPA for cost control
# policy/terraform.rego
package terraform

deny[msg] {
    resource := input.planned_values.root_module.resources[_]
    resource.type == "aws_instance"
    resource.values.instance_type == "x1e.32xlarge"
    msg := "Instance type x1e.32xlarge requires VP approval"

# Example: Replace Sentinel with OPA for cost control
# policy/terraform.rego
package terraform

deny[msg] {
    resource := input.planned_values.root_module.resources[_]
    resource.type == "aws_instance"
    resource.values.instance_type == "x1e.32xlarge"
    msg := "Instance type x1e.32xlarge requires VP approval"

# Example: Replace Sentinel with OPA for cost control
# policy/terraform.rego
package terraform

deny[msg] {
    resource := input.planned_values.root_module.resources[_]
    resource.type == "aws_instance"
    resource.values.instance_type == "x1e.32xlarge"
    msg := "Instance type x1e.32xlarge requires VP approval"

4. Provider Version Pins

Some older provider version constraints may resolve differently between the two registries. Always pin your provider versions explicitly.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 5.82.0"  # Pin exactly during migration
    }
  }
}
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 5.82.0"  # Pin exactly during migration
    }
  }
}
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 5.82.0"  # Pin exactly during migration
    }
  }
}

The Decision Framework: Which Tool Fits Your Team?

After working through dozens of these evaluations, here's the framework we use at SeedStack.

Stay with Terraform if:

  • You're deeply invested in Terraform Cloud or Terraform Enterprise

  • Your team uses Sentinel policies extensively and rewriting them isn't feasible right now

  • You have a stable, working setup and the license change doesn't impact your use case

  • Your organization's legal team has reviewed BSL 1.1 and confirmed you're in the clear

Migrate to OpenTofu if:

  • You're in a regulated industry where client-side state encryption matters

  • You're building a platform or managed service that could be construed as "competing" with HashiCorp

  • You want to avoid vendor lock-in on principle and have the engineering bandwidth to migrate

  • You're starting a greenfield project and want to bet on the community-governed option

  • Multi-region or multi-account deployments are a core use case (provider for_each is excellent)

Hedge your bets if:

  • Keep your .tf code compatible with both tools (avoid tool-specific features)

  • Use generic module sources instead of registry-specific URLs

  • Abstract your CI/CD pipeline so swapping terraform for tofu is a one-line change

  • Test against both tools in CI before committing to one

Our Take

The IaC landscape is healthier with two strong options. Competition drives innovation. OpenTofu has proven it can keep pace, and Terraform isn't going anywhere.

For most SeedStack clients — growing SaaS companies and startups scaling their infrastructure — we recommend starting new projects with OpenTofu and migrating existing projects opportunistically (when you're already doing a major infrastructure refactor, not as a standalone effort).

The migration itself is genuinely straightforward for teams not on Terraform Cloud. The tooling is compatible, the state format is identical, and the risk is low. The hardest part is usually organizational — getting buy-in, updating runbooks, and retraining muscle memory from terraform to tofu.

Whatever you choose, the most important thing is that your IaC is version-controlled, tested, and automated. The tool matters less than the practice.

Need help evaluating your IaC strategy or migrating to OpenTofu? Get a free infrastructure audit — we'll tell you exactly where you stand and what the migration looks like for your specific setup.

Read more posts

Image of client success manager

Hello 👋 I’m Jiten, Client success manager

If you’ve got any questions or just want to talk things through, i’m always happy to chat.

Contact us

By submitting, you agree to our Terms and Privacy Policy.