OpenTofu
12 min read
OpenTofu is an open-source tool for building, changing, and versioning infrastructure safely and efficiently. It can manage existing and popular service providers as well as custom in-house solutions. It originally derived from HashiCorp Terraform after its license change to BSL in 2023.
Key features:
• Infrastructure as Code: Infrastructure is described using a high-level configuration syntax (HCL), allowing your datacenter blueprint to be versioned, shared, and reused like any other code.
• Execution Plans: OpenTofu has a "planning" step that generates an execution plan showing exactly what will happen before apply is run — no surprises.
• Resource Graph: OpenTofu builds a dependency graph of all resources and parallelizes creation/modification of independent resources for maximum efficiency.
• Change Automation: Complex changesets can be applied with minimal human interaction. The execution plan and resource graph together make changes predictable and auditable.
Usage
Core Workflow
sh
# Initialize OpenTofu (downloads providers, configures the backend, writes the lock file)
tofu init
# Preview the changes that will be made (no changes are applied)
tofu plan
# Apply the changes (prompts for approval unless -auto-approve is given)
tofu apply
# Reconcile the state file with real-world infrastructure
tofu refresh
# Tear everything down
tofu destroy
Tip: Use tofu plan -out=tfplan to save a plan, then tofu apply tfplan to apply exactly that plan with no re-planning. This separation is the standard pattern in CI/CD — plan on PRs, apply the saved artifact on merge.
Inspecting Infrastructure
These read-only commands report on the current state and configuration without changing anything.
sh
# Human-readable dump of the current state (or a saved plan)
tofu show
tofu show -plan=tfplan # inspect a saved plan file
tofu show -json | jq # machine-readable, pipe into jq
# Print output values from the state
tofu output # all outputs
tofu output instance_ip # a single output
tofu output -raw instance_ip # unquoted string, ideal for shell scripts
tofu output -json | jq # all outputs as JSON
⚠️ tofu show -json and tofu output -json emit sensitive values in plain text. Never log or commit that output.
Dependency Graph
tofu graph emits the resource dependency graph in Graphviz DOT format, which dot renders into an image:
sh
# Render the configuration graph to SVG
tofu graph | dot -Tsvg > graph.svg
# Graph a specific operation, or hunt for dependency cycles
tofu graph -type=plan
tofu graph -type=apply
tofu graph -draw-cycles # highlight cycles for debugging
Workspaces
Workspaces let a single configuration manage multiple independent state files — typically one per environment (dev, staging, prod) — without duplicating code. Each workspace has its own state, but they share the same configuration and backend. The active workspace name is available in config as terraform.workspace.
sh
tofu workspace list # list workspaces (* marks the active one)
tofu workspace show # print the active workspace name
tofu workspace new staging # create and switch to a new workspace
tofu workspace select prod # switch to an existing workspace
tofu workspace delete staging # delete a workspace (must be empty)
hcl
# Reference the active workspace inside config
resource "aws_instance" "web" {
instance_type = terraform.workspace == "prod" ? "m5.large" : "t3.micro"
tags = {
Environment = terraform.workspace
}
}
Note: Workspaces are convenient for environment variants of the same infrastructure, but for strongly isolated environments many teams prefer separate state keys / directories instead, since a single misfired apply against the wrong workspace can be costly.
State
OpenTofu maintains a state file that tracks the current state of your infrastructure. This allows it to know what resources have been created and what changes need to be made. State is stored in .tfstate files.
⚠️ NEVER commit state files to version control. They contain sensitive information like secrets, IPs, and resource IDs.
Add to .gitignore:
gitignore
.terraform
.terraform.lock.hcl
terraform.tfstate
terraform.tfstate.backup
terraform.tfstate.d/
*.tfvars
Backends
By default, state is stored via the local backend — a plain-text terraform.tfstate file in your working directory. This is fine for solo development, but not suitable for teams or production.
In a team setting you need a centralized, lockable state backend. The two most common options are S3 and HTTP.
S3 Backend
The S3 backend is the most popular choice for AWS users. It supports state locking via DynamoDB.
hcl
terraform {
backend "s3" {
bucket = "my-tofu-state"
key = "prod/terraform.tfstate"
region = "eu-central-1"
# State locking (strongly recommended)
dynamodb_table = "tofu-state-lock"
encrypt = true
}
}
Setting up the DynamoDB lock table (one-time, can be done via the AWS CLI):
sh
aws dynamodb create-table \
--table-name tofu-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
The S3 bucket itself should have versioning enabled so you can recover from accidental state corruption:
sh
aws s3api put-bucket-versioning \
--bucket my-tofu-state \
--versioning-configuration Status=Enabled
HTTP Backend
A generic HTTP backend suitable for self-hosted state servers (e.g. GitLab, Terraform HTTP backend implementations):
hcl
terraform {
backend "http" {
address = "https://example.com/state/hydra-prod"
lock_address = "https://example.com/lock/hydra-prod"
unlock_address = "https://example.com/lock/hydra-prod"
lock_method = "POST"
unlock_method = "DELETE"
}
}
After configuring a new backend, run tofu init to migrate your existing state to it.
State Management
Sometimes you need to manipulate state directly — when refactoring config, recovering from drift, or cleaning up. The tofu state subcommands operate on the state file.
⚠️ Modifying state outside a normal plan/apply can make OpenTofu lose track of real resources — wasting money, breaking colleagues' work, or orphaning infrastructure. Prefer config-driven mechanisms (moved blocks, import blocks) when available, and always back up state first (tofu state pull > backup.tfstate).
Inspecting state:
sh
# List resources tracked in state (optionally filter by address)
tofu state list
tofu state list aws_instance.web
# Show the full attributes of one resource
tofu state show aws_instance.web
Refactoring — moving and removing:
sh
# Rename a resource, or move it into/out of a module, without destroying it
tofu state mv aws_instance.web aws_instance.frontend
tofu state mv aws_instance.web module.frontend.aws_instance.web
tofu state mv -dry-run ... # preview first
# Stop managing a resource WITHOUT destroying it in the real world
tofu state rm aws_instance.web
Tip: For renames you control in code, prefer a moved block over tofu state mv — it's declarative, reviewable in the diff, and runs as part of a normal apply:
hcl
> moved {
> from = aws_instance.web
> to = aws_instance.frontend
> }
> ```
Disaster recovery — pull / push:
sh
# Download remote state to stdout (good for backups or inspection)
tofu state pull > backup.tfstate
# Overwrite remote state from a local file (last-resort recovery — dangerous)
tofu state push backup.tfstate
Forcing re-creation:
sh
# Mark a resource so the next apply destroys and re-creates it
tofu taint aws_instance.web
tofu untaint aws_instance.web
The modern, plan-visible alternative to taint is -replace, which shows the recreation in the plan before you commit:
sh
tofu plan -replace="aws_instance.web"
tofu apply -replace="aws_instance.web"
Releasing a stuck lock:
If a plan/apply is interrupted (crash, lost connection), the state lock can be left held, blocking further runs. Release it manually with the lock ID printed in the error message:
sh
tofu force-unlock <LOCK_ID>
Syntax
OpenTofu configuration is written in HCL (HashiCorp Configuration Language). It's also possible to write raw HCL JSON, or use Nix via Terranix.
Variables
Variables allow you to parameterize your configuration and are referenced via var.<name>:
hcl
variable "cloudflare_api_token" {
type = string
description = "Cloudflare API token"
sensitive = true # Prevents the value from being shown in logs
}
variable "environment" {
type = string
default = "dev"
}
Variable values are resolved in this priority order (highest to lowest):
1. -var / -var-file CLI arguments
2. *.auto.tfvars files
3. terraform.tfvars
4. Environment variables: TF_VAR_<name>
5. Default values defined in the variable block
Locals
Locals are computed values you can reuse across your config without exposing them as inputs:
hcl
locals {
common_tags = {
environment = var.environment
managed_by = "opentofu"
}
}
resource "aws_instance" "web" {
# ...
tags = local.common_tags
}
Outputs
Outputs expose values after apply, useful for passing data between modules or displaying results:
hcl
output "instance_ip" {
value = aws_instance.web.public_ip
description = "Public IP of the web server"
}
Providers
Providers are plugins that let OpenTofu interact with external services and APIs. They're declared in a required_providers block and configured separately:
hcl
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5"
}
}
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
Some useful providers from the registry:
Resources
Resources represent actual infrastructure objects. They're the core building block of OpenTofu configuration:
hcl
resource "cloudflare_dns_record" "www" {
name = "www"
value = "1.2.3.4"
type = "A"
ttl = 3600
}
The key syntax is resource "<type>" "<name>" {} where <type> is provider-specific and <name> is your local identifier for referring to this resource elsewhere (e.g. cloudflare_dns_record.www.id).
Resources can be imported from existing infrastructure if the provider supports it:
sh
tofu import <resource_type>.<resource_name> <external_id>
# Example
tofu import cloudflare_dns_record.www abc123
Data Sources
Data sources let you fetch read-only information from providers without managing it:
hcl
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
# ...
}
Meta Arguments
Meta-arguments are special arguments accepted inside resource and module blocks (regardless of the provider) that change how OpenTofu manages the object.
count
count creates N identical instances of a resource or module. Each instance gets an index via count.index (starting at 0), and instances become a list addressed as <resource>[0], <resource>[1], …
hcl
resource "aws_instance" "server" {
count = 3
ami = "ami-a1b2c3d4"
instance_type = "t3.micro"
tags = {
Name = "server-${count.index}"
}
}
count can also act as a conditional toggle (count = var.enabled ? 1 : 0).
for_each
for_each creates one instance per entry of a map or set of strings. Each instance is keyed by a stable string (each.key / each.value) rather than a positional index, addressed as <resource>["key"].
hcl
resource "aws_instance" "server" {
for_each = toset(["web", "db", "cache"])
ami = "ami-a1b2c3d4"
instance_type = "t3.micro"
tags = {
Name = each.key
}
}
count vs. for_each: Prefer for_each whenever instances have meaningful identities. With count, removing an item from the middle of the list shifts every later index, so OpenTofu plans to destroy and recreate everything after it. With for_each, each instance is tied to a stable key, so removing one only affects that one. Use count for truly interchangeable instances or simple on/off toggles.
depends_on
Declares an explicit dependency when OpenTofu can't infer it from references. Use sparingly — implicit dependencies (referencing another resource's attribute) are preferred.
hcl
resource "aws_instance" "app" {
# ...
depends_on = [aws_iam_role_policy.app]
}
provider
Selects a non-default (aliased) provider configuration for a resource — useful for multi-region or multi-account setups.
hcl
provider "aws" {
alias = "eu"
region = "eu-central-1"
}
resource "aws_instance" "eu_server" {
provider = aws.eu
# ...
}
lifecycle
Customizes how OpenTofu creates, updates, and destroys a resource. Common settings:
hcl
resource "aws_instance" "web" {
# ...
lifecycle {
create_before_destroy = true # avoid downtime on replacement
prevent_destroy = true # guard against accidental deletion
ignore_changes = [tags] # don't revert out-of-band changes to these
}
}
moved blocks
moved blocks aren't a meta-argument but are closely related to refactoring: they record that a resource was renamed or relocated so OpenTofu updates state in place instead of destroying and recreating. They're the declarative alternative to tofu state mv.
hcl
moved {
from = aws_instance.web
to = aws_instance.frontend
}
Custom Conditions
OpenTofu can validate assumptions about inputs and outputs directly in configuration, surfacing clear error messages instead of obscure downstream failures. There are three kinds, differing in when they run and whether they halt the operation.
| Kind | Lives in | Runs | On failure |
|---|---|---|---|
| Variable validation | variable block | when the variable is set | error — halts |
| Pre/postcondition | resource/data/output lifecycle block | before/after that object is evaluated | error — halts |
| Check block | top-level check block | last, after plan/apply | warning — does not halt |
Variable Validation
Validates an input value before anything uses it, acting as a gatekeeper for module inputs:
hcl
variable "image_id" {
type = string
validation {
condition = startswith(var.image_id, "ami-")
error_message = "The image_id must be a valid AMI id, starting with \"ami-\"."
}
}
Preconditions & Postconditions
Declared inside a resource, data source, or output's lifecycle block. A precondition asserts something that must hold before the object is processed (an assumption); a postcondition asserts a guarantee after it's processed. Both refer to the object itself via self. A failure produces an error and halts the operation.
hcl
data "http" "opentofu_org" {
url = "https://www.opentofu.org"
lifecycle {
postcondition {
condition = self.status_code == 200
error_message = "${self.url} returned an unhealthy status code"
}
}
}
Check Blocks
A top-level check block validates infrastructure as a whole, independent of any single resource's lifecycle. It can include up to one scoped data source and one-or-more assert blocks, and runs last — after OpenTofu has planned or provisioned everything.
The crucial difference: a failed assert reports a warning and does not halt the operation, unlike pre/postconditions which error out. This makes checks ideal for continuous, non-blocking health validation (e.g. "is the deployed site actually serving 200s?").
hcl
check "health_check" {
data "http" "opentofu_org" {
url = "https://www.opentofu.org"
}
assert {
condition = data.http.opentofu_org.status_code == 200
error_message = "${data.http.opentofu_org.url} returned an unhealthy status code"
}
}
Modules
Modules are reusable, self-contained packages of OpenTofu configuration:
hcl
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
}
Modules can be sourced from the registry, a local path (./modules/vpc), or a Git URL.
Full Example (Cloudflare)
hcl
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 5"
}
}
backend "s3" {
bucket = "my-tofu-state"
key = "cloudflare/terraform.tfstate"
region = "eu-central-1"
}
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}
variable "cloudflare_api_token" {
type = string
sensitive = true
}
resource "cloudflare_dns_record" "www" {
name = "www"
value = "1.2.3.4"
type = "A"
ttl = 3600
}
output "record_hostname" {
value = cloudflare_dns_record.www.hostname
}