Skip to content

How-to Guides

Scope

Task recipes for running OpenTofu: installing it, migrating from Terraform, encrypting state, moving S3 locking off DynamoDB, using OCI registries, multi-region providers, keeping secrets out of state, CI/CD tracing, and troubleshooting. Look-up tables are in Reference. The reasons behind the behavior are in Explanation. Commands target the current stable series, v1.12.x. Each recipe notes the version it needs.

Install OpenTofu

Package managers and installer script

# Homebrew (macOS/Linux) - opentofu is in homebrew-core
brew update
brew install opentofu

# Official installer script (Linux/macOS/BSD). Download, inspect, then run.
curl --proto '=https' --tlsv1.2 -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
chmod +x install-opentofu.sh
./install-opentofu.sh --install-method standalone   # other methods: deb, rpm, apk, snap, brew
rm -f install-opentofu.sh

# Verify
tofu -version

Installer integrity checks

The standalone installer verifies downloads with cosign or GnuPG. Install one of them, or pass --skip-verify (not recommended). Avoid curl ... | sh: download the script and read it before you run it.

Verify a release binary manually

# After downloading tofu_<ver>_<os>_<arch>.zip plus tofu_<ver>_SHA256SUMS, .sig and .pem:
sha256sum --ignore-missing -c tofu_*_SHA256SUMS

OPENTOFU_VERSION_MAJORMINOR="1.12"
IDENTITY="https://github.com/opentofu/opentofu/.github/workflows/release.yml@refs/heads/v${OPENTOFU_VERSION_MAJORMINOR}"
cosign verify-blob \
  --certificate-identity "${IDENTITY}" \
  --signature tofu_*.sig \
  --certificate tofu_*.pem \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  tofu_*_SHA256SUMS

Build a container image (1.10+)

Since 1.10 the official ghcr.io/opentofu/opentofu images are not supported for direct use. Copy the binary from a -minimal image into your own image:

FROM ghcr.io/opentofu/opentofu:1.12-minimal AS tofu

FROM alpine:3.20
COPY --from=tofu /usr/local/bin/tofu /usr/local/bin/tofu
ENTRYPOINT ["/usr/local/bin/tofu"]

Migrate from Terraform

The official migration guide is designed to be safe and reversible.

# 1. Back up state (local example; for S3 make sure bucket versioning is on)
cp terraform.tfstate terraform.tfstate.pre-tofu
git switch -c migrate-to-opentofu

# 2. Install tofu (see above), then in the existing Terraform directory:
tofu init          # downloads providers from registry.opentofu.org, initializes the backend
tofu plan          # expect "No changes" or the same plan Terraform would show

# 3. Only if the plan is clean:
tofu apply         # lets OpenTofu update the state format if needed

# 4. Prove it with a small, harmless change (for example, add a tag), then plan/apply again

Drop-in compatibility

OpenTofu forked from Terraform 1.5.x. Configurations, state files and provider binaries written for Terraform 1.5 and earlier work unchanged. Replace terraform with tofu in scripts and CI. A configuration that uses features added only to Terraform after the fork needs review first. See Reference: feature differences.

Rolling back

If the plan shows unexpected changes, do not apply. Stop, restore your backups, and run terraform init && terraform plan. After you turn on state encryption, Terraform can no longer read the state. Treat encryption as the point of no return for rollback.

For multiple configurations linked by terraform_remote_state, follow Migrating interdependent configurations.

Write modules that work in both Terraform and OpenTofu (1.8+)

Keep the portable code in main.tf. Put the OpenTofu-only version in main.tofu. OpenTofu reads main.tofu and ignores main.tf. Terraform never sees .tofu files.

modules/network/
├── main.tf        # Terraform-compatible HCL
├── main.tofu      # same resources + OpenTofu-only features (e.g. provider for_each)
└── variables.tf   # shared by both tools

Core Workflow

tofu init                         # install providers/modules, configure backend
tofu fmt -recursive
tofu validate
tofu plan -out=plan.tfplan
tofu show plan.tfplan             # 1.13+: no longer needs to launch providers in most cases
tofu apply plan.tfplan
tofu destroy

Targeting and excluding resources

tofu plan -exclude=module.legacy_dns                 # 1.9+: everything except this
tofu apply -target-file=targets/production.txt       # 1.10+: one address per line, # comments
tofu apply -exclude-file=excludes/experimental.txt   # 1.10+

Machine-readable output alongside human output (1.12+)

tofu plan -json-into=plan-log.json    # terminal stays human-readable; JSON goes to the file

Manage State

tofu state list
tofu state show aws_instance.web
tofu state mv aws_instance.web aws_instance.frontend
tofu state rm aws_instance.orphan
tofu import aws_instance.web i-1234567890abcdef0
tofu force-unlock <LOCK_ID>          # last resort; confirm nobody else is running

Prefer declarative refactoring blocks over imperative state commands:

# Rename or move across resource types (1.10+ supports type changes, e.g. null_resource -> terraform_data)
moved {
  from = null_resource.example
  to   = terraform_data.example
}

# Stop managing an object without destroying it
removed {
  from = aws_instance.legacy
  lifecycle {
    destroy = false
  }
}

# Import by identity instead of ID string (1.12+, provider must support identity)
import {
  to       = aws_instance.web
  identity = { id = "i-abcd1234" }   # attribute names come from the resource's identity schema
}

Forget instead of destroy (1.12+)

lifecycle { destroy = false } on a managed resource makes OpenTofu forget the object (remove it from state) instead of destroying it. When a whole stack is torn down, tofu destroy -suppress-forget-errors exits 0 in that case.

Encrypt State and Plan Files

Encryption lives in the terraform { encryption { ... } } block, not in the backend block. You can also supply it through the TF_ENCRYPTION environment variable.

New project: passphrase (dev/test)

variable "state_passphrase" {
  type      = string
  sensitive = true # at least 16 characters
}

terraform {
  encryption {
    key_provider "pbkdf2" "dev" {
      passphrase = var.state_passphrase
    }
    method "aes_gcm" "default" {
      keys = key_provider.pbkdf2.dev
    }
    state {
      method   = method.aes_gcm.default
      enforced = true
    }
    plan {
      method   = method.aes_gcm.default
      enforced = true
    }
  }
}

Production: AWS KMS

terraform {
  encryption {
    key_provider "aws_kms" "prod" {
      kms_key_id = "alias/tofu-state-key"
      key_spec   = "AES_256"
      region     = "us-east-1"
    }
    method "aes_gcm" "default" {
      keys = key_provider.aws_kms.prod
    }
    state {
      method = method.aes_gcm.default
    }
    plan {
      method = method.aes_gcm.default
    }
  }
}

The other key providers are gcp_kms, azure_vault, openbao and external (experimental). Their options are in Reference: key providers.

Existing project: migrate plaintext state to encrypted

OpenTofu refuses to read plaintext state once encryption is configured. Allow it once with an unencrypted fallback:

terraform {
  encryption {
    method "unencrypted" "migrate" {}

    key_provider "aws_kms" "prod" {
      kms_key_id = "alias/tofu-state-key"
      key_spec   = "AES_256"
      region     = "us-east-1"
    }
    method "aes_gcm" "default" {
      keys = key_provider.aws_kms.prod
    }

    state {
      method = method.aes_gcm.default
      fallback {
        method = method.unencrypted.migrate
      }
    }
  }
}
cp terraform.tfstate terraform.tfstate.plaintext-backup   # or snapshot the remote bucket
tofu apply        # 1.9+ re-writes the state encrypted even when there are no resource changes
# then delete the fallback block and add: enforced = true

Rotate keys or change key providers

Put the old method in fallback. OpenTofu decrypts with it if the new method fails, and it always writes with the new method.

state {
  method = method.aes_gcm.new_key
  fallback {
    method = method.aes_gcm.old_key
  }
}

Run tofu apply. When every state has been re-written, remove the old key provider and method. Set encrypted_metadata_alias on a key provider if you expect to rename it later.

Roll back encryption

Reverse the migration: set method = method.unencrypted.migrate and put the old AES-GCM method in fallback. Apply, then remove the encryption block. Do not delete the old key until the rollback is finished.

Read encrypted remote state from another project

terraform {
  encryption {
    # key_provider / method blocks as above
    remote_state_data_sources {
      default {
        method = method.aes_gcm.default
      }
    }
  }
}

Switch S3 Locking from DynamoDB to a Lock File (1.10+)

terraform {
  backend "s3" {
    bucket         = "my-opentofu-state"
    key            = "prod/opentofu.tfstate"
    region         = "us-east-1"
    encrypt        = true           # SSE; separate from OpenTofu state encryption
    use_lockfile   = true           # native S3 lock via conditional writes
    dynamodb_table = "tofu-locks"   # keep during the transition, remove after a baking period
  }
}
tofu init -reconfigure

When nothing has gone wrong for a while, remove dynamodb_table and run tofu init -reconfigure again. Every run of every tool that uses this state must be on 1.10 or later before you drop DynamoDB.

Use an OCI Registry for Providers and Modules (1.10+)

Mirror providers through a container registry (useful for air-gapped setups) in the CLI configuration file (~/.tofurc on Unix, tofu.rc in %APPDATA% on Windows):

provider_installation {
  oci_mirror {
    repository_template = "registry.example.com/opentofu-providers/${namespace}/${type}"
    include             = ["registry.opentofu.org/*/*"]
  }
}

Use a module published as an OCI artifact:

module "vpc" {
  source = "oci://registry.example.com/terraform-modules/vpc/aws"
}

In 1.13+, tofu providers lock also takes -oci-mirror. It is the command-line equivalent of oci_mirror's repository_template, written as a URI template. See the 1.13 changelog for the exact syntax.

Patch level

Before 1.12.6, OpenTofu could re-send registry credentials to the target of a redirect from an OCI registry. Run a patched release if you use private OCI registries.

Deploy to Many Regions with Provider for_each (1.9+)

variable "regions" {
  type = set(string)
}
variable "disabled_regions" {
  type    = set(string)
  default = []
}

provider "aws" {
  alias    = "by_region"          # for_each requires an alias
  for_each = var.regions          # must be statically known (variables/locals)
  region   = each.value
}

module "deploy" {
  source   = "./deploy"
  for_each = setsubtract(var.regions, var.disabled_regions)
  providers = {
    aws = aws.by_region[each.key]
  }
}

Removing a region

To retire a region, first add it to disabled_regions and apply. That destroys the resources while the provider still exists. Only then remove the region from regions. If you remove the provider first, OpenTofu has no way to destroy the resources in that region.

Use Variables in Backend and Module Source Blocks (1.8+)

variable "env" {
  type = string
}

terraform {
  backend "s3" {
    bucket = "tofu-state-${var.env}"
    key    = "app/terraform.tfstate"
    region = "eu-west-1"
  }
}

module "app" {
  source = "git::https://example.com/modules/app.git?ref=${var.env}"
}
tofu init -var env=prod     # init reads -var/-var-file for early-evaluated values

Only variables and locals that do not depend on resources, data sources or module outputs are allowed here.

Keep Secrets Out of State with Ephemeral Values (1.11+)

variable "db_password" {
  type      = string
  ephemeral = true         # never persisted to state or plan
}

resource "aws_secretsmanager_secret" "db" {
  name = "prod/db"
}

resource "aws_secretsmanager_secret_version" "db" {
  secret_id                = aws_secretsmanager_secret.db.id
  secret_string_wo         = var.db_password   # write-only attribute
  secret_string_wo_version = 1                 # bump to push a new value
}

ephemeral "aws_secretsmanager_secret_version" "db" {
  secret_id  = aws_secretsmanager_secret.db.id
  depends_on = [aws_secretsmanager_secret_version.db]
}

Ephemeral values can flow only into ephemeral variables, ephemeral outputs, locals, provider blocks, provisioners, connection blocks, and write-only attributes. They need a provider version that implements ephemeral resources or write-only attributes. AWS provider 6.x is used in the docs example.

Create a Resource Conditionally with enabled (1.11+)

resource "aws_instance" "bastion" {
  ami           = var.ami_id
  instance_type = "t3.micro"

  lifecycle {
    enabled = var.create_bastion   # zero or one instance, no [0] indexing needed
  }
}

Protect Resources Per Environment (1.12+)

resource "aws_db_instance" "main" {
  # ...
  lifecycle {
    prevent_destroy = var.env == "prod"   # can now reference variables
  }
}

Share a Provider Cache in CI (1.10+)

export TF_PLUGIN_CACHE_DIR="$HOME/.cache/opentofu/plugins"
mkdir -p "$TF_PLUGIN_CACHE_DIR"
tofu init     # concurrent jobs/Terragrunt runs can share the cache safely (file lock)

Trace Slow Runs with OpenTelemetry (1.10+, experimental)

# Local Jaeger or Tempo with an OTLP gRPC receiver on :4317
export OTEL_TRACES_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_EXPORTER_OTLP_INSECURE=true
tofu plan

No telemetry leaves the machine unless you configure an exporter. In 1.13, local-exec child processes get TRACEPARENT.

Fetch Dynamic Provider Credentials from Vault or OpenBao

provider "vault" {
  address = "https://vault.example.com:8200"
}

# 1.11+: prefer the ephemeral form when the provider offers it, so creds never land in state
data "vault_generic_secret" "aws_creds" {
  path = "aws/creds/my-role"
}

provider "aws" {
  access_key = data.vault_generic_secret.aws_creds.data["access_key"]
  secret_key = data.vault_generic_secret.aws_creds.data["secret_key"]
}

Never hardcode credentials

Keep credentials out of .tf files. Use environment variables, OIDC or workload identity, IAM roles, or a secrets manager. Marking a variable sensitive = true only hides it in CLI output. The value is still in state unless it is ephemeral or the state is encrypted.

Pin Providers with the Lock File

tofu init                    # 1.12+: records h1: and zh: hashes for all platforms automatically
git add .terraform.lock.hcl  # always commit the lock file
tofu init -upgrade           # move to newer versions allowed by constraints
tofu providers lock -platform=linux_amd64 -platform=darwin_arm64   # only for mirrors/offline setups
# .terraform.lock.hcl (excerpt)
provider "registry.opentofu.org/hashicorp/aws" {
  version     = "5.45.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:...",   # hash of unpacked package contents
    "zh:...",   # SHA-256 of the distribution zip
  ]
}

Troubleshooting

Symptom Cause Fix
Failed to query available provider packages after migrating Provider not published to the OpenTofu Registry, or a network mirror is misconfigured Check search.opentofu.org. Set the full source in required_providers. Configure network_mirror or oci_mirror.
Lock file gains h1: lines after upgrading to 1.12 One-time backfill of registry-served hashes Expected. Commit the change.
tofu plan refuses to read existing state right after you add an encryption block Plaintext state is rejected by design, to block tampered data Add the unencrypted fallback (see above), apply, then remove it
State unreadable after renaming a key provider Encrypted metadata is keyed by the provider name Restore the old name, or add encrypted_metadata_alias or a fallback block
Error acquiring the state lock Another run holds the lock, or it crashed Wait. Check the CI runs. As a last resort, tofu force-unlock <ID>.
Lock conflicts on pg backend after upgrading 1.10 changed pg locking Never run 1.10+ and older versions against the same database
Provisioner connection { type = "winrm" } fails WinRM deprecated in 1.12, removed in 1.13 Switch to OpenSSH on Windows
Plan shows base64gzip resource updates after upgrading to 1.13 New DEFLATE implementation produces different bytes Expect a one-time diff. The data decompresses to the same content.
tofu init on a 32-bit platform warns about support 32-bit builds end after 1.13 Move to amd64 or arm64
Module works in Terraform but fails in tofu It uses a Terraform-only feature added after the fork Add a .tofu override file, or refactor

Sources