HCL & Terraform compatibility
Providers, resources, and shared syntax.
Two cooks, one recipe card. The card says proof the dough for an hour, then bake at 220C, and neither cook argues with it. Both loaves come out the same. OpenTofu and Terraform read your configuration files that way. The language on the card is HCL (HashiCorp Configuration Language, the block-and-argument syntax you write infrastructure in), and a .tf file means the same thing to either tool, down to the last comma. That is deliberate. The 2023 fork was an argument about the software license and who governs the project. Nobody set out to invent a new language.
So every module on the public registry, every snippet pasted into a runbook, every internal library your platform team already wrote parses unchanged. That is the good news, and it is most of the story. The rest of the story is a seam: a small set of places where the same directory can mean two different things depending on which binary you run. The seam is a real feature with a real purpose. It is also a tidy hiding spot. Learn it before somebody else finds it in your repository.
The Two Nouns You Write Most
A provider is a specialist contractor who holds the keys to one building and knows its rules. Amazon Web Services (AWS) is one building. Azure is another. A PostgreSQL database server or a GitHub organization is another still. Under the friendly word, a provider is a separate program that OpenTofu downloads and runs as a child process. The two halves talk over gRPC (a framework that lets one program call functions inside another program as if they were local), carried on a Unix domain socket, which is a private channel between two processes on the same machine. Both ends prove who they are with certificates. Your configuration never calls a cloud API (application programming interface, the HTTP endpoints a cloud exposes so machines can drive it) directly. It hands the work order to the contractor, and the contractor is the one carrying the credentials.
A resource is one line on that work order. There should be an S3 bucket (Simple Storage Service, Amazon's object storage) named acme-logs-prod, and it should look like this. OpenTofu owns that object from birth to death. It will create it, change it, or destroy it until reality matches the line you wrote. A data source is the read-only cousin. It looks something up and owns nothing, which makes it the safe way to point at infrastructure another team manages without quietly taking responsibility for it.
terraform { # the keyword is still "terraform", on purposerequired_version = ">= 1.6.0"required_providers {aws = {source = "hashicorp/aws" # -> registry.opentofu.org/hashicorp/awsversion = "~> 5.0" # any 5.x, never 6.0}}}provider "aws" {region = "us-east-1"}# resource: OpenTofu creates it, owns it, and will destroy itresource "aws_s3_bucket" "logs" {bucket = "acme-logs-prod"}# data source: read-only, owns nothingdata "aws_caller_identity" "current" {}
Read the first block again. The keyword is still terraform. OpenTofu kept it because renaming it would break every published module overnight. The same reasoning shows up all through the plumbing. The lock file is still .terraform.lock.hcl. The working directory is still .terraform/. The environment variables are still TF_VAR_db_password, TF_LOG, TF_CLI_ARGS and TF_DATA_DIR. When you script around OpenTofu in a pipeline, keep reaching for the TF_ names. The compatibility is not a coat of paint. It reaches into exactly the surfaces you automate against.
Where The Plugin Actually Comes From
source = "hashicorp/aws" is shorthand, and the piece it leaves out is the piece a defender cares about: the hostname. Under Terraform it expands to registry.terraform.io/hashicorp/aws. Under OpenTofu it expands to registry.opentofu.org/hashicorp/aws. Two different indexes pointing at the same upstream release archives from the same maintainers. The tofu init command is where that short name gets resolved, where the archive gets downloaded, and where its signature gets checked.
tofu versiontofu init
OpenTofu v1.12.4on linux_amd64Initializing the backend...Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 5.0"...- Installing hashicorp/aws v5.100.0...- Installed hashicorp/aws v5.100.0 (signed, key ID 34365D9472D7468F)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 providerselections it made above. Include this file in your version control repositoryso that OpenTofu can guarantee to make the same selections by default whenyou run "tofu init" in the future.OpenTofu has been successfully initialized!
Two lines in there are your evidence. The first is (signed, key ID 34365D9472D7468F). It says the release archive arrived with a detached signature checked using GPG (GNU Privacy Guard, the standard tool for proving a file came from whoever claims to have made it), and that hex string is the tail of HashiCorp's publishing key fingerprint. Record it somewhere you actually look, because it should never change quietly. The second is the lock file notice. That file pins the exact version and the exact checksums OpenTofu settled on. Commit it. It is the difference between "we run some 5.x of the AWS provider" and "we run this archive, with this checksum, on every machine that touches production."
One caveat, because the word signed reads stronger than it behaves. OpenTofu verifies a provider against a GPG key that the provider's namespace submitted to the OpenTofu registry. For hashicorp/aws that key is present and the check is genuine. For a provider whose key was never submitted, OpenTofu skips validation rather than failing, because OPENTOFU_ENFORCE_GPG_VALIDATION defaults to false. Key expiry is not enforced by default either, under OPENTOFU_ENFORCE_GPG_EXPIRATION. Set both to true in a pipeline and find out today which of your providers cannot pass, instead of assuming all of them already do.
tofu providersgrep -m1 '^provider' .terraform.lock.hcl
Providers required by configuration:.└── provider[registry.opentofu.org/hashicorp/aws] ~> 5.0Providers required by state:provider[registry.opentofu.org/hashicorp/aws]provider "registry.opentofu.org/hashicorp/aws" {
That was run in a directory with existing state, which is why the second section has anything in it; in a directory you have only just initialized it prints empty. If you moved a repository over from Terraform, the hostname is the tell. A lock file written by terraform init says registry.terraform.io/hashicorp/aws. After tofu init it says registry.opentofu.org/hashicorp/aws, because OpenTofu re-resolved the same provider through its own index and wrote down what it found there.
Checksums got better in OpenTofu 1.12. Modern tofu init now backfills a full set of hashes covering every platform the provider publishes, in both the zh: and h1: formats, so the first init after an upgrade usually adds lines to a lock file you thought was finished. That is expected. Where you still do the work by hand is an inherited lock file, written by Terraform or by an older OpenTofu, that does not cover your platform. Run tofu providers lock -platform=linux_amd64 -platform=darwin_arm64 and commit the result, so a laptop and a Linux runner verify against one shared list instead of each trusting whatever it happened to download.
Wiring, And The Order You Never Have To Write
A configuration is a wiring diagram, not a flat list of parts. An input variable is the dial on the front of the box. A local is a name you give to a value you worked out once and use five times. An output is what you hand back to whoever called you, including your own terminal. A reference is the glue: the moment one resource reads another's attribute, you have told OpenTofu which of the two has to exist first.
Nothing sequences those steps by hand. Writing aws_s3_bucket.logs.id inside the versioning resource draws an edge in a dependency graph, and OpenTofu walks that graph to work out what can run in parallel and what has to wait its turn. Everything else you already know carries over untouched. The ternary operator, count, for_each, for expressions, the built-in functions, and depends_on for ordering that no reference can express, such as an IAM (Identity and Access Management, the AWS service that decides who is allowed to do what) policy that has to land before an instance boots. Identical syntax. Identical behavior.
variable "environment" {type = stringdefault = "prod"}locals {name_prefix = "acme-${var.environment}"}resource "aws_s3_bucket" "logs" {bucket = "${local.name_prefix}-logs"}resource "aws_s3_bucket_versioning" "logs" {bucket = aws_s3_bucket.logs.id # this reference IS the dependencyversioning_configuration {status = "Enabled"}}# the block that keeps a log bucket off the public internetresource "aws_s3_bucket_public_access_block" "logs" {bucket = aws_s3_bucket.logs.idblock_public_acls = trueblock_public_policy = trueignore_public_acls = truerestrict_public_buckets = true}output "bucket_arn" {value = aws_s3_bucket.logs.arn}
The One Seam: .tf Versus .tofu
Here is the exception to all that sameness. Since OpenTofu 1.8 the tool also reads .tofu and .tofu.json files, and the rule is one sentence long. If region.tf and region.tofu both sit in a directory, tofu loads region.tofu and does not read region.tf at all. Not a merge of the two. A whole-file replacement, keyed on the base name. Terraform never sees the .tofu file either, because .tofu is not on the list of extensions it loads.
It behaves like a sticky note taped over one card in a shared recipe box, written in ink only one of the two cooks can see. The intended use is real, and the OpenTofu docs spell it out: keep a versions.tf that declares Terraform compatibility, and a versions.tofu beside it that uses OpenTofu-only syntax. One repository serves both tools, and you avoid maintaining two full copies that drift apart by hand.
# read by BOTH terraform and tofu... unless a .tofu twin existslocals {aws_region = "us-east-1"}
# read ONLY by tofu, and it replaces region.tf entirelylocals {aws_region = "us-west-2"}
lstofu init > /dev/nullecho 'local.aws_region' | tofu console
region.tf region.tofu"us-west-2"
The duplicate locals block is what turns this into a proof rather than a claim. Had OpenTofu parsed both files, it would have stopped with Error: Duplicate local value definition and refused to go further. It printed a value instead. So exactly one file was read, and it was the .tofu one.
Making The Seam Visible In Review
A pull request that adds providers.tofu and touches nothing else is close to invisible in a diff. The file a reviewer knows to open, providers.tf, is unchanged and still says what it always said. Meanwhile the plan your CI (continuous integration, the automated job that runs on every pull request) produces is running under the assume-role ARN (Amazon Resource Name, the unique identifier AWS gives every object it manages) from the new file. Human attention is the wrong control here. Put the list of override files under machine review instead.
#!/usr/bin/env bash# Fail the build on any .tofu override nobody signed off on.# A .tofu file silently replaces its .tf twin, so the list must be explicit.set -euo pipefailallowlist="ci/tofu-overrides.allow" # one path per line, LC_ALL=C sortedfound="$(mktemp)"trap 'rm -f "$found"' EXITgit ls-files -- '*.tofu' '*.tofu.json' | LC_ALL=C sort > "$found"if ! diff -u "$allowlist" "$found"; thenecho "ERROR: OpenTofu override files do not match the allowlist." >&2echo "Each one replaces a .tf file that reviewers may never open." >&2exit 1fiecho "OK: .tofu overrides match the allowlist."
bash ci/check-tofu-overrides.sh; echo "exit=$?"
--- ci/tofu-overrides.allow 2026-07-21 09:41:12.884113221 +0000+++ /tmp/tmp.9kQ2VwRb1c 2026-07-21 09:41:12.892113198 +0000@@ -1 +1,2 @@+infra/prod/providers.tofuinfra/prod/region.tofuERROR: OpenTofu override files do not match the allowlist.Each one replaces a .tf file that reviewers may never open.exit=1
Run that in the same job as tofu fmt -check -recursive and tofu validate. The formatter does process .tofu files, so a -check failure will also surface an override that nobody bothered to format. It does not process .tofu.json, which is one more reason your allowlist covers both extensions and the formatter is not the only net under you. While you are in there, add the allowlisted override paths to your CODEOWNERS file (the list that says which named people must approve changes under which paths), so touching one always costs somebody a deliberate approval.
Where The Compatibility Actually Stops
State is the other place the two tools part company. Every state snapshot records the version that wrote it, and neither tool will load a snapshot stamped with a version number higher than its own. You can read the stamp yourself with jq (a command-line reader for JSON, the text format the state file is written in) before you inherit somebody else's directory.
jq -r '.version, .terraform_version, .serial' terraform.tfstatetofu plan
41.15.837╷│ Error: Error loading state: state snapshot was created by OpenTofu v1.15.8,│ which is newer than current v1.12.4; upgrade to OpenTofu v1.15.8 or greater│ to work with this state╵
That message names OpenTofu even though Terraform v1.15.8 wrote the file. The snapshot stores a version number and nothing about which product produced it, so the comparison runs on the number alone and the product name in the text comes from whichever binary is doing the complaining. Resist the urge to hand-edit terraform_version downward to make the error disappear. The schema behind that number may genuinely have changed, and you would find out during an apply rather than during a plan.
Past the version number, each side now has syntax the other cannot parse. OpenTofu 1.12 added a language block for declaring which OpenTofu versions a module targets, and Terraform rejects it outright. Early evaluation of variables inside module sources and backend blocks landed in 1.8. Iterating providers with for_each landed in 1.9. Neither has a Terraform equivalent. Turn on OpenTofu's client-side state encryption (1.7 and later) and the snapshot becomes ciphertext that Terraform has no code to decrypt.
All of which shapes how reversible the move is. Going back to Terraform is not a matter of swapping the binary, because once OpenTofu has written state the stamp reads 1.12.4, and a Terraform 1.5.x from before the fork will refuse it as newer. The supported route back is the one in OpenTofu's own migration guide: keep the pre-migration state backup, restore it, and run a plan under Terraform to confirm nothing drifted while you were away. That backup goes stale in proportion to how much you applied after the switch. Write the date you crossed over into the repository README, and note where the backup lives, because the next person will ask about both.
tofu init prints "(signed, key ID ...)" for hashicorp/aws, but the lesson warns the word "signed" reads stronger than it behaves. For a third-party provider whose namespace never submitted a GPG key to the OpenTofu registry, what does OpenTofu do by default?.terraform.lock.hcl after running tofu init on an Apple-silicon Mac. Your Linux CI runner then fails at tofu init, reporting that the provider's recorded checksums do not cover its platform. What is the right fix?tofu providers lock exists for.One habit pays for itself. Before you approve any infrastructure change, run git ls-files -- '*.tofu' '*.tofu.json' in the repository and read what comes back. If a path on that list is one you cannot account for, the plan you are approving is not the plan you read.
Try this
Run tofu version on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a .tofu file silently shadows its .tf twin. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.