TL;DR — Quick Summary
OpenTofu is the Linux Foundation's open-source Terraform fork. Learn installation, migration, state encryption, HCL syntax, and CI/CD pipeline integration.
OpenTofu is the open-source fork of Terraform maintained under the Linux Foundation, created after HashiCorp changed Terraform’s license from Mozilla Public License 2.0 to the Business Source License (BSL) in August 2023. This guide covers everything you need to understand OpenTofu’s origins, install it, migrate existing Terraform projects with zero friction, master HCL fundamentals, manage state securely with client-side encryption, and integrate it into modern CI/CD pipelines.
Why OpenTofu Exists: The License Fork Story
Terraform was open source under MPL 2.0 for over nine years. On August 10, 2023, HashiCorp announced that Terraform 1.6 and all future versions would be released under the Business Source License (BSL 1.1) — a source-available license that restricts using the software to compete with HashiCorp commercially.
The community response was swift. Within days, engineers from Spacelift, Gruntwork, Env0, Harness, Scalr, and others signed an open letter and created the OpenTofu fork from the last MPL 2.0 release (Terraform 1.5.7). The Linux Foundation accepted OpenTofu as a project in September 2023, and OpenTofu 1.6.0 — the first stable release with new features — shipped in January 2024.
Key implications of the license change:
- Terraform OSS is now BSL — you cannot build a competing product with it
- OpenTofu stays MPL 2.0 — fully open source, no commercial restrictions
- HashiCorp (now IBM) controls Terraform Cloud and Terraform Enterprise
- OpenTofu is community-governed, with a Technical Steering Committee and open RFC process
OpenTofu vs Terraform Compatibility
OpenTofu 1.6–1.9 targets drop-in compatibility with Terraform 1.5.x:
| Feature | OpenTofu | Terraform OSS |
|---|---|---|
| HCL syntax | Identical to TF 1.5 | Same |
| Provider ecosystem | registry.opentofu.org + terraform.io fallback | registry.terraform.io |
| State file format | Binary-compatible .tfstate | Same format |
.terraform.lock.hcl | Works unchanged | Same |
terraform binary commands | Replaced 1:1 with tofu | terraform |
| State encryption | Yes (AES-GCM, KMS) | No |
| Early variable evaluation | Yes (1.8+) | No |
| Provider-defined functions | Yes (1.8+) | No |
removed block | Yes (1.7+) | Partial |
| Testing framework | Yes (inherited from TF 1.6) | Yes |
| Terraform Cloud | Not compatible | Native |
The practical migration cost for most teams is a search-and-replace of terraform → tofu in CI scripts and shell aliases. The HCL files, providers, modules, and state files are untouched.
Installation
apt (Debian / Ubuntu)
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://get.opentofu.org/opentofu.gpg | \
sudo tee /etc/apt/keyrings/opentofu.gpg > /dev/null
curl -fsSL https://packages.opentofu.org/opentofu/tofu/gpgkey | \
sudo gpg --no-tty --batch --dearmor -o /etc/apt/keyrings/opentofu-repo.gpg > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/opentofu.gpg,/etc/apt/keyrings/opentofu-repo.gpg] \
https://packages.opentofu.org/opentofu/tofu/any/ any main" | \
sudo tee /etc/apt/sources.list.d/opentofu.list
sudo apt update && sudo apt install -y opentofu
tofu version
dnf (RHEL / Fedora / Rocky)
sudo tee /etc/yum.repos.d/opentofu.repo <<'EOF'
[opentofu]
name=opentofu
baseurl=https://packages.opentofu.org/opentofu/tofu/rpm_any/rpm_any/$basearch
enabled=1
gpgcheck=1
gpgkey=https://get.opentofu.org/opentofu.gpg
EOF
sudo dnf install -y opentofu
Homebrew (macOS / Linux)
brew install opentofu
asdf version manager
asdf plugin add opentofu
asdf install opentofu 1.9.0
asdf global opentofu 1.9.0
Docker
docker run --rm -v $(pwd):/workspace -w /workspace \
ghcr.io/opentofu/opentofu:1.9 tofu plan
Migrating from Terraform
Step 1 — Install OpenTofu alongside Terraform
Both binaries can coexist. tofu and terraform are separate executables.
Step 2 — Run tofu init in your existing project
cd my-terraform-project/
tofu init
OpenTofu reads the same versions.tf / required_providers block. It downloads providers from registry.opentofu.org (which mirrors registry.terraform.io for all community providers). Your .terraform.lock.hcl is reused as-is.
Step 3 — Replace terraform with tofu in CI scripts
# Before
terraform fmt -check
terraform init -input=false
terraform plan -out=tfplan
terraform apply -auto-approve tfplan
# After (zero logic change)
tofu fmt -check
tofu init -input=false
tofu plan -out=tfplan
tofu apply -auto-approve tfplan
Step 4 — Verify state compatibility
The .tfstate JSON format is identical. If you manage remote state (S3, GCS, Azure Blob), no migration is needed — OpenTofu reads and writes the same state file. Run a plan with OpenTofu: it should show no changes if the infrastructure matches the existing state.
tofu plan
# Expected: No changes. Your infrastructure matches the configuration.
HCL Fundamentals
Providers
# versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = var.aws_region
}
Resources and Data Sources
# Create a VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project}-vpc"
Environment = var.environment
}
}
# Look up the latest Amazon Linux 2023 AMI
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
}
Variables, Locals, and Outputs
# variables.tf
variable "aws_region" {
type = string
description = "AWS region for all resources"
default = "us-east-1"
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "project" {
type = string
}
# locals.tf
locals {
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "opentofu"
}
az_count = length(data.aws_availability_zones.available.names)
}
# outputs.tf
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}
count and for_each
# count — create N identical-ish resources
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = merge(local.common_tags, {
Name = "${var.project}-public-${count.index + 1}"
Tier = "public"
})
}
# for_each — create resources from a map
variable "buckets" {
type = map(object({
versioning = bool
lifecycle_days = number
}))
default = {
logs = { versioning = false, lifecycle_days = 30 }
backups = { versioning = true, lifecycle_days = 90 }
assets = { versioning = true, lifecycle_days = 365 }
}
}
resource "aws_s3_bucket" "this" {
for_each = var.buckets
bucket = "${var.project}-${each.key}-${data.aws_caller_identity.current.account_id}"
tags = merge(local.common_tags, { Name = each.key })
}
resource "aws_s3_bucket_versioning" "this" {
for_each = { for k, v in var.buckets : k => v if v.versioning }
bucket = aws_s3_bucket.this[each.key].id
versioning_configuration {
status = "Enabled"
}
}
Dynamic blocks
variable "ingress_rules" {
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
description = string
}))
default = [
{ port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTPS" },
{ port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"], description = "HTTP redirect" },
]
}
resource "aws_security_group" "web" {
name = "${var.project}-web-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = local.common_tags
}
State Management
Remote backends
# S3 + DynamoDB (most common for AWS)
terraform {
backend "s3" {
bucket = "my-tofu-state-bucket"
key = "prod/infra/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tofu-state-locks"
encrypt = true
}
}
# GCS
terraform {
backend "gcs" {
bucket = "my-tofu-state"
prefix = "prod/infra"
}
}
# Azure Blob
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tofustate"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
# PostgreSQL
terraform {
backend "pg" {
conn_str = "postgres://user:pass@db.example.com/tofu_state"
schema_name = "prod"
}
}
Client-Side State Encryption (OpenTofu exclusive)
OpenTofu 1.7+ adds client-side encryption of the state file before it is uploaded to the backend. This means even if someone gains access to your S3 bucket or storage backend, the state file is unreadable without the key.
# AES-GCM with a passphrase (simplest, good for dev/staging)
terraform {
encryption {
key_provider "pbkdf2" "my_passphrase" {
passphrase = var.state_encryption_passphrase
}
method "aes_gcm" "my_method" {
keys = key_provider.pbkdf2.my_passphrase
}
state {
method = method.aes_gcm.my_method
}
}
}
# AWS KMS (recommended for production)
terraform {
encryption {
key_provider "aws_kms" "prod_key" {
kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123"
region = "us-east-1"
key_spec = "AES_256"
}
method "aes_gcm" "prod_method" {
keys = key_provider.aws_kms.prod_key
}
state {
method = method.aes_gcm.prod_method
}
plan {
method = method.aes_gcm.prod_method
}
}
}
Useful state commands
# List all resources in state
tofu state list
# Show details for a specific resource
tofu state show aws_instance.web[0]
# Import an existing cloud resource into state
tofu import aws_s3_bucket.assets my-existing-bucket
# Remove a resource from state without destroying it
tofu state rm aws_s3_bucket.old
# Move a resource to a new address (after renaming)
tofu state mv aws_instance.web aws_instance.app
# Pull raw state JSON
tofu state pull > backup.tfstate
# Unlock a stuck state (after interrupted apply)
tofu force-unlock LOCK_ID
Modules
Modules are the primary mechanism for reusing and sharing infrastructure code.
# modules/vpc/main.tf — a reusable VPC module
variable "project" { type = string }
variable "cidr" { type = string }
variable "az_count" { type = number; default = 3 }
resource "aws_vpc" "this" {
cidr_block = var.cidr
tags = { Name = "${var.project}-vpc" }
}
output "vpc_id" { value = aws_vpc.this.id }
output "subnet_ids" { value = aws_subnet.public[*].id }
Module sources
# Local path
module "vpc" {
source = "./modules/vpc"
project = var.project
cidr = "10.0.0.0/16"
}
# Git tag
module "vpc" {
source = "git::https://github.com/org/infra-modules.git//vpc?ref=v2.3.0"
project = var.project
cidr = "10.1.0.0/16"
}
# OpenTofu / Terraform registry
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0"
cluster_name = "${var.project}-${var.environment}"
cluster_version = "1.29"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.subnet_ids
}
Workspaces
CLI workspaces let you maintain multiple state files from a single configuration, useful for environment separation without duplicating code.
# Create and switch to a new workspace
tofu workspace new staging
tofu workspace new prod
# List workspaces
tofu workspace list
# default
# staging
# * prod
# Switch workspace
tofu workspace select staging
# Reference the current workspace in HCL
locals {
env_config = {
dev = { instance_type = "t3.micro", min_size = 1, max_size = 2 }
staging = { instance_type = "t3.small", min_size = 2, max_size = 4 }
prod = { instance_type = "t3.medium", min_size = 3, max_size = 10 }
}
current = local.env_config[terraform.workspace]
}
CI/CD Integration
GitHub Actions with OIDC (no stored credentials)
# .github/workflows/tofu.yml
name: OpenTofu Plan and Apply
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
id-token: write # Required for OIDC
contents: read
pull-requests: write
jobs:
tofu:
runs-on: ubuntu-latest
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
steps:
- uses: actions/checkout@v4
- name: Setup OpenTofu
uses: opentofu/setup-opentofu@v1
with:
tofu_version: "1.9.0"
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsTofu
aws-region: us-east-1
- name: tofu init
run: tofu init -input=false
- name: tofu fmt check
run: tofu fmt -check -recursive
- name: tofu validate
run: tofu validate
- name: tofu plan
id: plan
run: tofu plan -out=tfplan -input=false
- name: Comment plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const plan = require('fs').readFileSync('plan.txt', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '```\n' + plan + '\n```'
});
- name: tofu apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: tofu apply -auto-approve tfplan
GitLab CI
# .gitlab-ci.yml
variables:
TF_ROOT: ${CI_PROJECT_DIR}
TOFU_VERSION: "1.9.0"
stages: [validate, plan, apply]
.tofu-base:
image: ghcr.io/opentofu/opentofu:${TOFU_VERSION}
before_script:
- tofu init -input=false
validate:
extends: .tofu-base
stage: validate
script:
- tofu fmt -check -recursive
- tofu validate
plan:
extends: .tofu-base
stage: plan
script:
- tofu plan -out=tfplan -input=false
artifacts:
paths: [tfplan]
expire_in: 1 day
apply:
extends: .tofu-base
stage: apply
script:
- tofu apply -auto-approve tfplan
dependencies: [plan]
when: manual
only: [main]
Tool Comparison
| Feature | OpenTofu | Terraform OSS | Terraform Cloud | Pulumi | Crossplane | CDK for TF |
|---|---|---|---|---|---|---|
| License | MPL 2.0 | BSL 1.1 | Proprietary | Apache 2.0 | Apache 2.0 | MPL 2.0 |
| Language | HCL | HCL | HCL | Python/TS/Go | YAML/Go | Python/TS/Java |
| State storage | Any backend | Any backend | Managed | Pulumi Cloud or self-hosted | Kubernetes etcd | Any backend |
| Remote execution | Local/CI | Local/CI | Managed runners | Local/CI | In-cluster | Local/CI |
| State encryption | Yes (built-in) | No | Yes (managed) | Yes (managed) | N/A | No |
| Provider ecosystem | Terraform providers | Terraform providers | Terraform providers | Pulumi providers + TF bridge | Kubernetes CRDs | Terraform providers |
| Learning curve | Low (HCL) | Low (HCL) | Low | Medium (real code) | High (K8s CRDs) | Medium |
| Best for | Open-source IaC | Small teams OK with BSL | Teams wanting managed backend | Developers who prefer code | Kubernetes-native teams | Developers who dislike HCL |
Practical Example: AWS Infrastructure
This example provisions a production-ready AWS environment with VPC, subnets, security groups, an EC2 Auto Scaling Group behind an Application Load Balancer, and encrypted state.
# main.tf
data "aws_availability_zones" "available" { state = "available" }
data "aws_caller_identity" "current" {}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(local.common_tags, { Name = "${var.project}-vpc" })
}
resource "aws_subnet" "public" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = merge(local.common_tags, { Name = "${var.project}-public-${count.index + 1}" })
}
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = merge(local.common_tags, { Name = "${var.project}-private-${count.index + 1}" })
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, { Name = "${var.project}-igw" })
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = merge(local.common_tags, { Name = "${var.project}-public-rt" })
}
resource "aws_route_table_association" "public" {
count = var.az_count
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_lb" "main" {
name = "${var.project}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
tags = local.common_tags
}
resource "aws_lb_target_group" "app" {
name = "${var.project}-tg"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/health"
healthy_threshold = 2
unhealthy_threshold = 3
interval = 30
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.acm_certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
resource "aws_autoscaling_group" "app" {
name = "${var.project}-asg"
vpc_zone_identifier = aws_subnet.private[*].id
target_group_arns = [aws_lb_target_group.app.arn]
min_size = local.current.min_size
max_size = local.current.max_size
desired_capacity = local.current.min_size
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
tag {
key = "Name"
value = "${var.project}-app"
propagate_at_launch = true
}
}
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
Gotchas and Edge Cases
Provider registry fallback: OpenTofu checks registry.opentofu.org first. If a provider is only on registry.terraform.io, add an explicit source in required_providers. Most major providers are mirrored automatically.
State encryption migration: Enabling encryption on an existing unencrypted state file is a one-way operation per key. Plan the rollout carefully — document your key ARN and ensure multiple team members have KMS access before enabling.
Early variable evaluation (1.8+): Variables can now be used in backend and provider blocks. This removes the need for partial backend configuration workarounds in many setups.
Workspace isolation: CLI workspaces share the same backend bucket but use different state keys. They do NOT provide isolation of cloud credentials, IAM roles, or provider configurations — use separate root modules (or separate tofu init directories) for true environment isolation.
Lock file with registry change: After switching from Terraform to OpenTofu, run tofu providers lock -platform=linux_amd64 -platform=darwin_arm64 to regenerate .terraform.lock.hcl with signatures from registry.opentofu.org. Commit the updated lock file.
Summary
- OpenTofu was forked from Terraform 1.5.7 in August 2023 after HashiCorp’s BSL license change, and is now a Linux Foundation project under MPL 2.0
- It is a drop-in replacement for Terraform 1.5.x — same HCL, same providers, same state format; migration requires only substituting
tofuforterraformin commands - Client-side state encryption (AES-GCM, AWS KMS, GCP KMS) is OpenTofu’s most significant new feature — unavailable in Terraform OSS
- Early variable evaluation (1.8+) allows variables in
backendandproviderblocks, eliminating many partial-config workarounds - Install via
apt,dnf,brew,asdf, Docker, or thesetup-opentofuGitHub Action - CI/CD integration is straightforward: replace
terraformwithtofu, use OIDC for cloud auth, and follow the plan → review → apply workflow - Use modules and
for_eachwith maps to build composable, DRY infrastructure configurations