Two tools, one language

Terraform is a declarative infrastructure as code tool: you describe resources in HCL files, it shows a plan of the changes, and it applies them through provider plugins (the Cloud topic gives a first overview). It was created by HashiCorp and released as open source under the Mozilla Public License (MPL) 2.0. In August 2023 HashiCorp moved all future releases to the Business Source License (BSL) 1.1, which allows most use but restricts offering Terraform in competing commercial products; Terraform 1.6.0 was the first release under it. HashiCorp has been part of IBM since 2025, and IBM is now the licensor named in Terraform's license file.

OpenTofu is the fork that a group of companies and community members started from the last MPL version in 2023. It is run under the Linux Foundation, joined the Cloud Native Computing Foundation (CNCF) as a sandbox project in April 2025, and keeps the MPL 2.0 license. Its first stable release was 1.6.0 in January 2024. As of October 2026 the current versions are OpenTofu 1.13.1 and Terraform 1.16.5.

For a beginner the two are interchangeable: the same HCL files, the same providers, the same init, plan and apply. The command is tofu instead of terraform, and the output says "OpenTofu" where Terraform says "Terraform". This page uses OpenTofu, because its output was recorded for real; every command works the same with terraform unless the text says otherwise. Kestrel Energy chose OpenTofu because its legal team prefers the MPL license; the differences that matter later are at the end of this page.

$ tofu version
OpenTofu v1.13.1
on linux_arm64

The first configuration

Real configurations talk to clouds. To keep everything on this page reproducible without a cloud account, Ravi Kumar's first configuration manages things on the local machine: a randomly generated deployment ID, a database password, and the nginx upstream file that tells nginx which portal servers to send traffic to. The mechanics — create, change, replace, destroy — are exactly the same as for a virtual machine.

portal/
├── versions.tf       which OpenTofu and provider versions
├── variables.tf      inputs
├── main.tf           the resources
├── outputs.tf        values to show after apply
└── templates/
    └── upstream.conf.tftpl

The file names are a convention, not a rule: OpenTofu reads all .tf files in the folder together. Start with versions.tf:

terraform {
  required_version = ">= 1.10"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.7"
    }
  }
}

Providers

OpenTofu itself knows nothing about clouds, files or passwords. A provider is a plugin that teaches it one platform: it knows the platform's API, which resource types exist there, and how to create, read, update and delete each one. hashicorp/aws manages Amazon Web Services, hashicorp/azurerm Azure, hashicorp/local local files and hashicorp/random random values. Providers are separate programs with their own version numbers and release cycles.

required_providers says which providers this configuration needs, where they come from (source) and which versions are acceptable. ~> 2.5 means "2.5 or any later 2.x, but not 3.0", so a major release with breaking changes never arrives by surprise; Modules and Reuse explains version constraints in detail.

Resources

variables.tf declares two inputs with defaults (the next page covers variables properly):

variable "environment" {
  description = "Name of the environment (dev, test or prod)."
  type        = string
  default     = "dev"
}

variable "web_servers" {
  description = "Backend servers for the customer portal."
  type        = list(string)
  default     = ["192.0.2.11", "192.0.2.12"]
}

main.tf holds the resources. A resource is one object that OpenTofu creates and then manages for its whole life: a virtual machine, a DNS record, a firewall rule — or, here, a file and two random values:

resource "random_id" "deployment" {
  byte_length = 4
}

resource "random_password" "db" {
  length  = 20
  special = false
}

resource "local_file" "upstream" {
  filename = "${path.module}/out/portal-upstream.conf"
  content = templatefile("${path.module}/templates/upstream.conf.tftpl", {
    environment = var.environment
    servers     = var.web_servers
    deployment  = random_id.deployment.hex
  })
  file_permission = "0644"
}

A resource block has two labels: the resource type (local_file, defined by the provider whose name is the first part, local) and a local name you choose (upstream). The arguments inside are the settings the provider documents for that type. The template file that fills the upstream is shown on Configuration Languages.

Notice random_id.deployment.hex inside the local_file: a reference to an attribute of another resource. It tells OpenTofu that the file depends on the random ID, so the ID must be created first. You never write the order; OpenTofu builds a dependency graph from the references.

Resource addresses

Every resource has a resource address that identifies it in plans, in the state and on the command line: type.name, such as local_file.upstream or random_password.db. When a resource has several instances the address gets an index or key — local_file.web[0], local_file.service["portal"] — and a resource inside a module is prefixed with the module, as in module.portal.local_file.upstream. You will type these addresses in commands such as tofu state show, so it pays to recognize their shape.

Finally, outputs.tf:

output "upstream_file" {
  value = local_file.upstream.filename
}

output "deployment_id" {
  value = random_id.deployment.hex
}

output "db_password" {
  value     = random_password.db.result
  sensitive = true
}

The cycle: init, plan, apply

The workflow is the same for every configuration, from this one to a whole data center:

The OpenTofu cycle. Write .tf files. tofu init downloads the providers into the .terraform folder and writes the dependency lock file. tofu plan compares the configuration with the state and the real resources and shows the changes. tofu apply makes the changes and updates the state. Then the cycle starts again with the next edit. Write *.tf tofu init providers, lock file tofu plan read and compare tofu apply change, save state next edit: plan and apply again (init only when providers, modules or the backend change)
init once per working folder; plan and apply for every change.

tofu init

tofu init prepares the folder: it reads required_providers, finds versions that match the constraints, downloads and verifies them, and connects to the state backend (here the default: a local file). The first time, in a folder with no lock file:

$ cd /home/amara/kestrel-infra/portal
$ tofu init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/random versions matching "~> 3.7"...
- Finding hashicorp/local versions matching "~> 2.5"...
- Installing hashicorp/local v2.9.1...
- Installing hashicorp/random v3.9.1...
- Installed hashicorp/local v2.9.1 (signed, key ID 0C0AF313E5FD9F80)
- Installed hashicorp/random v3.9.1 (signed, key ID 0C0AF313E5FD9F80)

Providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://opentofu.org/docs/cli/plugins/signing/

OpenTofu has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that OpenTofu can guarantee to make the same selections by default when
you run "tofu init" in the future.

OpenTofu has been successfully initialized!

You may now begin working with OpenTofu. Try running "tofu plan" to see
any changes that are required for your infrastructure. All OpenTofu commands
should now work.

If you ever set or change modules or backend configuration for OpenTofu,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

The constraint ~> 3.7 was satisfied by the newest 3.x release on the day, random 3.9.1; on another day it could be a later 3.x. The providers came from a provider registry: a service that publishes provider versions, their downloads for each operating system and processor, and their checksums and signatures. The short source hashicorp/local is expanded to a full address on the tool's default registry — registry.opentofu.org/hashicorp/local for OpenTofu, registry.terraform.io/hashicorp/local for Terraform. The downloaded plugins land in a hidden folder:

$ find .terraform -maxdepth 6 -type d | sort
.terraform
.terraform/providers
.terraform/providers/registry.opentofu.org
.terraform/providers/registry.opentofu.org/hashicorp
.terraform/providers/registry.opentofu.org/hashicorp/local
.terraform/providers/registry.opentofu.org/hashicorp/local/2.9.1
.terraform/providers/registry.opentofu.org/hashicorp/local/2.9.1/linux_arm64
.terraform/providers/registry.opentofu.org/hashicorp/random
.terraform/providers/registry.opentofu.org/hashicorp/random/3.9.1
.terraform/providers/registry.opentofu.org/hashicorp/random/3.9.1/linux_arm64

.terraform/ is a cache of binaries for this machine (note linux_arm64). It can be deleted and recreated with tofu init at any time, and it does not belong in Git. OpenTofu keeps the folder name .terraform for compatibility.

The dependency lock file

The dependency lock file, .terraform.lock.hcl, records which exact provider versions init selected and the checksums of their packages. It plays the same role as a lock file in npm or Composer: the constraint says what is allowed, the lock file says what was chosen. The start of Ravi's lock file (most hashes left out):

# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.

provider "registry.opentofu.org/hashicorp/local" {
  version     = "2.9.1"
  constraints = "~> 2.5"
  hashes = [
    "h1:0h5XUt+skYQCSCf+BHzPWHwaqVqo0I+6fasc7/9fP2Y=",
    "h1:5Y/Fp+WG8q5OO7fbf832bKjGJtKwrUmo0dK3e87ByVM=",
    …
    "zh:1127941790dbded1031144cd3617ecb44cdac28dbb4fd32b231f9b9f5f3c957c",
    "zh:1418b8844bbf5ac941afa1ff2ec3e1c61f923a2777d8aa7ec4a6e6b8e66df57c",
    …
  ]
}

provider "registry.opentofu.org/hashicorp/random" {
  version     = "3.9.1"
  constraints = "~> 3.7"
  …
}

The h1: hashes are checksums of the provider package for each platform; the zh: hashes come from the registry's signed list of checksums. On every later init, OpenTofu installs exactly the locked version and refuses a download whose checksum does not match — protection against a tampered package as well as against "it worked on my laptop". This is what a colleague sees after cloning the repository, with the lock file committed and no .terraform folder yet:

$ tofu init | head -12

Initializing the backend...

Initializing provider plugins...
- Reusing previous version of hashicorp/random from the dependency lock file
- Reusing previous version of hashicorp/local from the dependency lock file
- Installing hashicorp/random v3.9.1...
- Installing hashicorp/local v2.9.1...
- Installed hashicorp/random v3.9.1 (signed, key ID 0C0AF313E5FD9F80)
- Installed hashicorp/local v2.9.1 (signed, key ID 0C0AF313E5FD9F80)

Providers are signed by their developers.

No "Finding … versions matching" lines: the lock file decided. To move to newer providers on purpose, run tofu init -upgrade, review the changed lock file, and commit it like any other change. A lock file written by Terraform names providers on registry.terraform.io, so switching tools also changes the lock file.

tofu validate and tofu plan

tofu validate checks the files for syntax errors, unknown arguments and wrong references, without contacting any platform. It is fast, and the right first check after an edit:

$ tofu validate
Success! The configuration is valid.

tofu plan then produces an execution plan: the list of actions OpenTofu would take to make the real world match the configuration, worked out by comparing the configuration, the state and the current real resources. A plan changes nothing. The first plan of the portal configuration:

$ tofu plan

OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

OpenTofu will perform the following actions:

  # local_file.upstream will be created
  + resource "local_file" "upstream" {
      + content              = (known after apply)
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0644"
      + filename             = "./out/portal-upstream.conf"
      + id                   = (known after apply)
    }

  # random_id.deployment will be created
  + resource "random_id" "deployment" {
      + b64_std     = (known after apply)
      + b64_url     = (known after apply)
      + byte_length = 4
      + dec         = (known after apply)
      + hex         = (known after apply)
      + id          = (known after apply)
    }

  # random_password.db will be created
  + resource "random_password" "db" {
      + bcrypt_hash = (sensitive value)
      + id          = (known after apply)
      + length      = 20
      + lower       = true
      + min_lower   = 0
      + min_numeric = 0
      + min_special = 0
      + min_upper   = 0
      + number      = true
      + numeric     = true
      + result      = (sensitive value)
      + special     = false
      + upper       = true
    }

Plan: 3 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + db_password   = (sensitive value)
  + deployment_id = (known after apply)
  + upstream_file = "./out/portal-upstream.conf"

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so OpenTofu can't
guarantee to take exactly these actions if you run "tofu apply" now.

How to read it:

  • The legend at the top lists only the symbols that occur in this plan. The full set is + create, ~ update in place, - destroy, -/+ destroy and then create a replacement, +/- create the replacement first and then destroy, and <= read (for data sources).
  • Each resource starts with a comment line naming its address and the action: # local_file.upstream will be created. Other wordings you will meet are "will be updated in-place", "must be replaced" and "will be destroyed".
  • (known after apply) marks values the provider only learns when it creates the object: the random value, the hash of the file's content, the ID a cloud assigns.
  • (sensitive value) hides values the provider or you marked as sensitive. Hidden in the output does not mean protected — see the warning below.
  • Attributes you never set, such as directory_permission = "0777", show the provider's defaults. Plans are a good way to discover them.
  • The summary line, Plan: 3 to add, 0 to change, 0 to destroy., is the one line you must never skip.

The note at the end matters for teams: between this plan and a later apply, someone else could change the infrastructure, so apply plans again. Pipelines therefore save the plan with -out and apply exactly that file; see Changing Infrastructure Safely.

tofu apply

tofu apply plans once more, shows the plan, and asks for confirmation. Only the exact word yes continues; anything else stops the apply without changing anything. The question looks like this (the plan above it is left out):

Plan: 3 to add, 0 to change, 0 to destroy.
…
Do you want to perform these actions?
  OpenTofu will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

In the recordings the confirmation was given on the command line with -auto-approve, which skips the question. The plan part is identical to the one above, so it is shortened here:

$ tofu apply -auto-approve
…
Plan: 3 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + db_password   = (sensitive value)
  + deployment_id = (known after apply)
  + upstream_file = "./out/portal-upstream.conf"
random_id.deployment: Creating...
random_id.deployment: Creation complete after 0s [id=CoDo_w]
random_password.db: Creating...
local_file.upstream: Creating...
local_file.upstream: Creation complete after 0s [id=577cb8ce792092da6359d85e27648b4f11d5a85a]
random_password.db: Creation complete after 0s [id=none]

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

db_password = <sensitive>
deployment_id = "0a80e8ff"
upstream_file = "./out/portal-upstream.conf"

The order shows the dependency graph at work: the random ID was created first, and the file only after it, because the file's content refers to the ID. The password depends on nothing, so OpenTofu was free to create it at the same time as the file. Each line in brackets is the id of the new object. The file now exists:

$ cat out/portal-upstream.conf
# Kestrel Energy customer portal (dev), deployment 0a80e8ff
upstream kestrel_portal {
    server 192.0.2.11:8080;
    server 192.0.2.12:8080;
}

Where do CoDo_w and 0a80e8ff come from? random_id generated four random bytes and offers them in several spellings: its id is the bytes in URL-safe Base64, hex the same bytes in hexadecimal, dec as one decimal number. A quick check:

import base64

resource_id = "CoDo_w"  # the id OpenTofu printed for random_id.deployment

# random_id stores its bytes as unpadded base64url; add the padding back
raw = base64.urlsafe_b64decode(resource_id + "=" * (-len(resource_id) % 4))

print("bytes:", len(raw))
print("hex:  ", raw.hex())
print("dec:  ", int.from_bytes(raw, "big"))
bytes: 4
hex:   0a80e8ff
dec:   176220415

Your own run produces different random values, and therefore a different file hash and ID; the messages and the summary lines are the same.

Outputs and what OpenTofu remembers

tofu output prints the output values again at any time. OpenTofu also wrote a state file, terraform.tfstate, recording which real object belongs to which resource address; tofu state list shows the addresses it now manages:

$ tofu output
$ tofu state list
db_password = <sensitive>
deployment_id = "0a80e8ff"
upstream_file = "./out/portal-upstream.conf"
local_file.upstream
random_id.deployment
random_password.db
$ tofu output db_password
"cyJYJeiYc9v95H3k8plU"

(A throwaway value generated for the recording.) Treat the state file as a secret, keep it out of Git, and read State: How Terraform Remembers before putting a real password in one.

Run tofu plan again and the cycle closes: the configuration and the real world agree, so there is nothing to do. OpenTofu refreshes each resource first — it reads the real object to check it still matches the state:

$ tofu plan
random_id.deployment: Refreshing state... [id=CoDo_w]
random_password.db: Refreshing state... [id=none]
local_file.upstream: Refreshing state... [id=577cb8ce792092da6359d85e27648b4f11d5a85a]

No changes. Your infrastructure matches the configuration.

Terraform prints the same "No changes" line followed by a short explanatory paragraph; OpenTofu dropped that paragraph in version 1.13.

tofu destroy

tofu destroy removes everything the configuration manages: it plans a destroy for every resource in the state and, after confirmation, deletes them. Its question is sterner than apply's: "Do you really want to destroy all resources?", followed by "There is no undo. Only 'yes' will be accepted to confirm." This recording destroys a one-file configuration for the meter-api (the -var sets the release number that configuration needs):

$ cd /home/amara/kestrel-infra/meter-api
$ tofu destroy -auto-approve -var release=2026.10.2
local_file.meter_api: Refreshing state... [id=414c0880dfcf3c05c5ae82ba8d755a9d52c769ab]

OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  - destroy

OpenTofu will perform the following actions:

  # local_file.meter_api will be destroyed
  - resource "local_file" "meter_api" {
      - content              = <<-EOT
            METER_API_RELEASE=2026.10.2
        EOT -> null
…
      - filename             = "./out/meter-api-2026.10.2.env" -> null
      - id                   = "414c0880dfcf3c05c5ae82ba8d755a9d52c769ab" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.
local_file.meter_api: Destroying... [id=414c0880dfcf3c05c5ae82ba8d755a9d52c769ab]
local_file.meter_api: Destruction complete after 0s

Destroy complete! Resources: 1 destroyed.

Every attribute is shown going to null. Afterward the state is empty:

$ tofu show
The state file is empty. No resources are represented.

destroy is the right tool for short-lived test environments. On anything that holds data it is the most dangerous command in the toolbox: a destroyed database is gone, together with its backups if they lived inside the same resource. Changing Infrastructure Safely shows how to protect such resources against accidental deletion.

What goes into Git

After the first run the folder holds files with very different jobs. A .gitignore for an OpenTofu or Terraform project:

# Provider and module downloads: recreated by tofu init
.terraform/

# State and its backups: may contain secrets, belongs in a backend
*.tfstate
*.tfstate.*

# Saved plans: contain values from the state
*.tfplan

# Crash logs
crash.log
File or folder Commit? Why
*.tf, *.tofu, templates Yes The configuration itself
.terraform.lock.hcl Yes Everyone gets the same provider versions
.terraform/ No Downloaded binaries for one machine
terraform.tfstate, backups No Can contain secrets; teams keep it in a remote backend
Saved plan files No Contain the same sensitive values as the state

The Git topic lists what never belongs in a repository in general. A state file that was committed once stays in the history even after you delete it; treat any secret in it as leaked and rotate it.

How Terraform and OpenTofu differ

The two projects share their history up to 2023 and the core language, but each has added features since. As of October 2026:

Terraform OpenTofu
Command terraform tofu
License Business Source License 1.1 (since 1.6.0) Mozilla Public License 2.0
Steward HashiCorp, an IBM company Linux Foundation; CNCF sandbox project
Current version 1.16.5 1.13.1 (supported until 1 August 2027)
Default registry registry.terraform.io registry.opentofu.org
Only in this tool (examples) terraform query and action blocks (1.14), HCP Terraform and Terraform Stacks State and plan encryption (1.7), .tofu files (1.8), provider for_each and -exclude (1.9), the enabled meta-argument (1.11)

Some features exist in both but arrived at different versions: ephemeral values and write-only attributes, which keep secrets out of state (Terraform 1.10 and 1.11, OpenTofu 1.11), and variables in module sources (OpenTofu 1.8, Terraform 1.15). When you read a tutorial, check which tool and version it was written for.

Switching an existing configuration is, for most configurations, a matter of installing the other tool and running init — the files and the state format are compatible at the level most teams use. It is not automatic for configurations that use features only one tool has, so read the migration guide for your versions, take a backup of the state, and switch the whole team and pipeline at once.