Terraform and OpenTofu: First Steps
Writing and applying a first configuration: providers and resources, the init, plan and apply cycle, the lock file, destroying what you made, and how Terraform and OpenTofu differ.
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:
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.