OpenTofu & Pulumi

The fork, and IaC in real languages.

Intermediate12 min · lesson 13 of 23

A restaurant owner rewrites the terms printed inside the cover of the recipe book. The recipes still work. The cooks can still read them and cook from them. But the new terms say you may not use these recipes to open a competing restaurant, so a group of cooks photocopies the last edition that carried no such clause and keeps cooking from the copy. That copy is a fork. In August 2023 HashiCorp did the licensing equivalent to Terraform, and the photocopy is called OpenTofu. Pulumi, the other tool in this lesson, argues something different: it keeps the plan-and-apply model and drops the idea that you should learn a special language to use it.

What The License Change Actually Says

Terraform up to and including version 1.5.5 shipped under the MPL (Mozilla Public License, an open source license with one string attached: if you modify one of its files and ship the result, you publish that file's source). Every release after that, starting with 1.5.6, carries the BUSL (Business Source License, sometimes written BSL), which is source-available rather than open source. You can read the code, run it, patch it, and use it to run your own company's infrastructure. What you cannot do is build a product that hosts, wraps, or resells Terraform in competition with HashiCorp. Each BUSL release also flips to MPL four years after it shipped, so the restriction expires on a rolling schedule instead of lasting forever.

The practical question for you is narrow. If you work for a vendor whose product hosts or resells Terraform, your legal team already has an opinion and you should go ask them. If you are an engineer applying infrastructure at an ordinary company, the license almost certainly does not touch anything you do, and the real reasons to look at OpenTofu are governance and features. OpenTofu was forked from the last MPL-licensed code, is developed in the open under the Linux Foundation, and reached general availability as version 1.6.0 in January 2024. Since then it has grown things Terraform does not have: encrypted state, for_each on provider blocks (one configuration block that expands into many, so you can loop one stack over several regions or accounts), and variables that get evaluated early enough to use in a backend or a module source.

Switching Is A Rename, Then A Diff

Moving a repository from Terraform to OpenTofu is closer to swapping one brand of wrench for another than to a rewrite. Same HCL (HashiCorp Configuration Language, the syntax your .tf files are written in, where you describe the end state you want instead of the steps to get there). Same providers. Same state format. Same environment variables, right down to the TF_ prefix on TF_VAR_region and TF_LOG. The block that declares your required providers is still spelled terraform, on purpose, so one shared module parses under both tools. State, by the way, is the tool's ledger: a file listing everything it built and the real cloud IDs it built them under. The new binary is called tofu, and before you run it, take a copy of that ledger you can fall back to.

terminal
# 1. pull a state backup you can restore from, before touching anything.
# state is plaintext secrets, so 077 makes the file readable only by you
$ mkdir -p ~/state-backups
$ ( umask 077; terraform state pull > ~/state-backups/prod-network.tfstate.bak )
$ ls -l ~/state-backups/prod-network.tfstate.bak
# 2. ask each binary what it thinks it is
$ terraform version
$ tofu version
output
-rw------- 1 deploy deploy 48213 Jul 21 09:12 /home/deploy/state-backups/prod-network.tfstate.bak
Terraform v1.5.5
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v5.70.0
Your version of Terraform is out of date! The latest version
is 1.13.3. You can update by downloading from https://developer.hashicorp.com/terraform/downloads
OpenTofu v1.10.6
on linux_amd64

That out-of-date notice is the shape of a lot of real estates. The team pinned Terraform at 1.5.5 to stay on MPL code and has been sitting there ever since. Sitting still costs something, because language fixes and provider compatibility keep landing upstream while you do not move. OpenTofu is one way out of the stalemate, and 1.5.x happens to be the version it migrates from most cleanly. Now look at the provider line under Terraform. That address, registry.terraform.io, is the part of the switch that changes something a defender cares about.

Prove The Move Changed Nothing

Your .terraform.lock.hcl file is a packing list with tamper seals on it. For every provider it records the exact version plus a set of checksums for the archive you are allowed to install, and each entry is keyed by the provider's full address, registry host included. OpenTofu resolves the shorthand hashicorp/aws through registry.opentofu.org rather than registry.terraform.io, so the old entries do not apply to it. Leaving them in place gets you either a second set of entries under the new host or a checksum error, depending on what is still sitting in your .terraform directory. The documented move is to delete the lock, let OpenTofu write a fresh one, and read the diff before you commit it. Reading that diff is the whole point. A lock file nobody looks at is a checksum nobody checks.

terminal
$ rm .terraform.lock.hcl # let OpenTofu record its own provider addresses
$ tofu init
output
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.70.0...
- Installed hashicorp/aws v5.70.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 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.
.terraform.lock.hcl
# the registry host in the address changed; the archive hashes did not,
# because both registries point at the same signed GitHub release assets
provider "registry.opentofu.org/hashicorp/aws" {
version = "5.70.0"
constraints = "~> 5.0"
hashes = [
"h1:U5U9rDvVQnH6xz1v0nDrVGpJoVJ9SzhkQGGvNGkPZjw=",
"zh:0d4fb4b1d1e1a3b0a8e1f9c1b0b2f1e4a9c7d5f3b1a0e8c6d4b2f0a9e7c5d3b1",
# ... one zh: entry per platform archive, truncated here
]
}
terminal
# the migration is correct only if the plan is empty.
# -lock-timeout waits a minute for the state lock instead of failing instantly
$ tofu plan -lock-timeout=60s
output
aws_vpc.main: Refreshing state... [id=vpc-0a1b2c3d4e5f60718]
aws_s3_bucket.logs: Refreshing state... [id=acme-logs-prod]
aws_db_instance.orders: Refreshing state... [id=orders-prod]
No changes. Your infrastructure matches the configuration.
OpenTofu has compared your real infrastructure against your configuration and
found no differences, so no changes are needed.

Those two words, "No changes", are your acceptance test. A tool migration should move zero resources, so anything else means something went wrong and you stop. The usual causes are a provider version that drifted upward when the lock was rewritten, or a configuration that was never clean in the first place and you are only now looking at it properly. To tell those apart, run terraform plan on the old binary. If the old binary shows the same change, the world drifted and the new tool is innocent.

The migration path runs one way
OpenTofu documents migration from Terraform 1.5.x and 1.6.x, and that direction is well travelled. Going back is not supported at all, and a state file written by a much newer Terraform can carry feature metadata OpenTofu refuses to open, which turns a casual experiment into an outage. Keep the pulled backup on disk until several applies have gone clean, and do the first switch on a stack whose destruction would annoy nobody.

Where Your Providers Come From Changes

A provider is a plugin in the sense that a printer driver is a plugin: a separate program the tool downloads and then runs on your behalf. It runs on your laptop or on your CI (continuous integration) runner, with your cloud credentials sitting in its environment, and it can do anything those credentials allow. So "which server handed me this binary, and who signed it" is a live security question, and switching tools changes the answer. OpenTofu's registry points at the provider author's own signed release artifacts on GitHub, which is why the archive checksums above match the ones Terraform recorded. Its init output prints the GPG (GNU Privacy Guard, the standard tool for signing files) key ID that signed those checksums. 34365D9472D7468F is HashiCorp's published signing key. Notice it once so that you notice when it is different.

terminal
# record hashes for every platform your team and your CI actually run on,
# so a Mac laptop and a Linux runner validate against the same lock file
$ tofu providers lock -platform=linux_amd64 -platform=darwin_arm64
output
- Fetching hashicorp/aws 5.70.0 for linux_amd64...
- Retrieved hashicorp/aws 5.70.0 for linux_amd64 (signed, key ID 34365D9472D7468F)
- Fetching hashicorp/aws 5.70.0 for darwin_arm64...
- Retrieved hashicorp/aws 5.70.0 for darwin_arm64 (signed, key ID 34365D9472D7468F)
- Obtained hashicorp/aws checksums for linux_amd64; All checksums for this
platform were already tracked in the lock file
- Obtained hashicorp/aws checksums for darwin_arm64; Additional checksums for
this platform are now tracked in the lock file
Success! OpenTofu has updated the lock file.
Review the changes in .terraform.lock.hcl above and commit them to your
version control system if they represent changes you intended to make.

Commit that file. Then make CI run tofu init -input=false -lockfile=readonly. With that flag, init refuses to modify the lock, so a provider that would have been quietly upgraded or swapped fails the build instead of shipping. The attacks on this path are boring and effective: a typosquatted namespace one keystroke away from the real one, a compromised release, a maintainer account taken over. None of them survive a pinned version plus a committed lock plus a build that will not rewrite it. The thing to watch for in review is a lock file diff nobody asked for, sitting in a pull request that claims to change something else.

State Encryption Is The Reason To Move

State is a JSON (JavaScript Object Notation, a plain-text data format) document holding every attribute your providers handed back, and providers hand back everything: generated database passwords, private keys, session tokens, connection strings. Bucket-side encryption, the SSE (server-side encryption) checkbox on your S3 (Simple Storage Service, Amazon's object store) bucket, protects the disks underneath the storage service. Picture a locked warehouse where the guard hands any parcel to anyone holding a valid badge, because the service decrypts on the way out for every caller allowed to read the object. OpenTofu can encrypt the bytes inside its own process, before they ever leave your machine. That is a locked box inside the warehouse.

encryption.tofu
terraform {
encryption {
key_provider "aws_kms" "prod" {
kms_key_id = "arn:aws:kms:us-east-1:444455556666:key/1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809"
region = "us-east-1"
key_spec = "AES_256"
}
method "aes_gcm" "prod" {
keys = key_provider.aws_kms.prod
}
state {
method = method.aes_gcm.prod # the state file itself
}
plan {
method = method.aes_gcm.prod # and every saved plan artifact
}
}
}

Three pieces, each doing one job. The key_provider says where the key comes from: aws_kms or gcp_kms for a managed KMS (Key Management Service, the cloud's key custody service), or pbkdf2 (Password-Based Key Derivation Function 2) to grind a passphrase into a key. The method says how the bytes get protected, here AES-GCM (Advanced Encryption Standard in Galois/Counter Mode, which encrypts and also detects tampering). The state and plan blocks say what is covered. Encrypting saved plans matters as much as encrypting state, because a plan file carries the same secret values and CI systems love to keep build artifacts around for weeks. The file extension is deliberate too. OpenTofu reads .tofu files and Terraform ignores them, so putting this block in encryption.tofu will not break a colleague who has not switched yet.

terminal
$ tofu apply -auto-approve >/dev/null
# now read the object exactly as a leaked read-only credential would
$ aws s3 cp s3://acme-tofu-state/prod/network.tfstate - | head -c 96; echo
$ aws s3 cp s3://acme-tofu-state/prod/network.tfstate - | grep -c 's3cr3t-prod-db-pw'
output
{"meta":{"key_provider.aws_kms.prod":{"ciphertext":"AQIDAHjRi9x0k2vQ8mB7rLc4vT1yF6nZ8wKd3sJ0hG5e
0

That zero is the verification. Before the encryption block, the same grep prints 1 and the password sits there in readable JSON for anyone holding s3:GetObject. After it, the file is an opaque envelope: a small block of key metadata that only a kms:Decrypt call can open, wrapped around the real payload. A leaked bucket credential, an over-broad bucket policy, or a plan artifact lifted off a CI runner no longer hands over your database. When you rotate the key, make the new method primary and list the old one in a fallback block so OpenTofu writes with the new key while still reading anything encrypted with the old one. Then confirm the rotation the same way you confirmed the first encryption: read the raw object and grep for something you know used to be in it.

Client-side encryption moves the risk onto the key
If the KMS key is deleted or the passphrase is lost, that state is gone and there is no recovery path. Use a managed key with a real key policy and a scheduled-deletion window, never a static passphrase committed next to the code. Remember too that every runner and every engineer who runs tofu needs decrypt permission on that key, so whoever can assume the CI role can still read everything. Encryption stops a leaked bucket credential from becoming a breach. It does not replace bucket policy, versioning, and access logging.

Pulumi Writes Infrastructure In A Language You Can Test

HCL is a form with labeled boxes. A general-purpose programming language is a blank notebook. Both can describe a building, and one of them is much harder to fill in wrongly. Pulumi keeps the model you already know (a description of the end state, a preview, an apply, a state file mapping your code to real cloud object IDs) and swaps the language for TypeScript, Python, Go, C#, Java, or plain YAML (Yet Another Markup Language, a plain-text configuration format). You get ordinary loops, functions, classes, your package manager, and your language's test framework pointed at infrastructure. Many Pulumi providers are generated from the equivalent Terraform provider, so resource names and arguments look familiar on day one.

index.ts (Pulumi, TypeScript)
import * as aws from "@pulumi/aws";
for (const env of ["dev", "staging", "prod"]) {
const b = new aws.s3.BucketV2(`data-${env}`, {
tags: { Environment: env },
});
// the guardrail lives inside the same loop, so it cannot be
// forgotten on exactly one bucket at 5pm on a Friday
new aws.s3.BucketPublicAccessBlock(`data-${env}-noPublic`, {
bucket: b.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
});
}
terminal
# state does not have to live in Pulumi Cloud; point it at your own bucket
$ pulumi login s3://acme-pulumi-state
$ pulumi stack init prod --secrets-provider="awskms://alias/pulumi-prod?region=us-east-1"
$ pulumi preview
output
Logged in to ip-10-0-3-14 as deploy (s3://acme-pulumi-state)
Created stack 'prod'
Previewing update (prod):
Type Name Plan
+ pulumi:pulumi:Stack data-buckets-prod create
+ ├─ aws:s3:BucketV2 data-dev create
+ ├─ aws:s3:BucketPublicAccessBlock data-dev-noPublic create
+ ├─ aws:s3:BucketV2 data-staging create
+ ├─ aws:s3:BucketPublicAccessBlock data-staging-noPublic create
+ ├─ aws:s3:BucketV2 data-prod create
+ └─ aws:s3:BucketPublicAccessBlock data-prod-noPublic create
Resources:
+ 7 to create

A stack is Pulumi's word for an environment: dev, staging, prod, each with its own state and its own configuration file. pulumi up is apply. pulumi destroy is destroy. pulumi stack export and pulumi stack import are the state surgery tools you reach for when something needs repairing by hand. If you are only evaluating a move, pulumi convert --from terraform --language typescript --out ./ts will translate an existing configuration into something you can read. Treat the result as a first draft and review every resource it emits.

Pulumi Encrypts Secrets Before They Hit Disk

Here Pulumi answers the same problem OpenTofu solves with its encryption block, and answers it without being asked. Any configuration value you mark secret is encrypted with the stack's secrets provider before it is written anywhere, and it stays encrypted inside state. Compare that with a terraform.tfvars file, which is plain text, easy to paste into a chat window, and one .gitignore mistake away from living in your repository history forever. The snippet below feeds one of those secret values straight into an RDS (Relational Database Service, Amazon's managed database) instance.

db.ts (Pulumi, TypeScript)
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const cfg = new pulumi.Config();
// requireSecret returns a value that carries the secret marker with it,
// so Pulumi knows to keep it encrypted everywhere it ends up
new aws.rds.Instance("orders", {
engine: "postgres",
instanceClass: "db.t4g.small",
allocatedStorage: 20,
username: "app",
password: cfg.requireSecret("dbPassword"),
skipFinalSnapshot: true,
});
terminal
$ pulumi config set aws:region us-east-1
$ pulumi config set --secret dbPassword 's3cr3t-prod-db-pw'
$ cat Pulumi.prod.yaml
# and check that state holds ciphertext too, not the value.
# jq is a command-line filter for JSON
$ pulumi stack export | jq '.deployment.resources[]
| select(.type=="aws:rds/instance:Instance") | .outputs.password'
output
secretsprovider: awskms://alias/pulumi-prod?region=us-east-1
encryptedkey: AQIDAHj0Xk2pQ9mB7rLc4vT1yF6nZ8wKd3sJ0hG5eR2bN4xM==
config:
aws:region: us-east-1
data-buckets:dbPassword:
secure: v1:mQ8nX2Vd0pKz:AWlQ7Rr9pO2yF1cH4tK6bN0sZgU3vE8xLd==
{
"4dabf18193072939515e22adb298388d": "1b47061264138c4ac30d75fd1eb44270",
"ciphertext": "AAABAJ9tQ1oQ0Zc3Pm5yR7kFb2H4xW6sT8uV0nD1gE=="
}

That first field, the long hexadecimal key with no obvious meaning, is the marker Pulumi stamps on a value to remember it is a secret. The marker travels with the value as it flows through your program, so a password used to build a connection string usually stays a secret in state as well. Usually is the word doing the work in that sentence. The same freedom that lets you write a clean loop lets you write a clever abstraction three files deep that a reviewer cannot hold in their head, which is the honest argument in favor of HCL's narrowness. Keep infrastructure code boring. Keep the control flow flat. Spend the language's power on tests rather than on cleverness.

Secret tracking ends where your own code writes
Pulumi prints [secret] in previews and keeps ciphertext in state, but the moment your program hands that value somewhere Pulumi does not control, the protection is over. A console.log left in during debugging puts the password in the CI log forever. Passing it into instance user-data or into a resource tag puts it in cloud metadata that any process on the host can read. The command pulumi stack output dbPassword --show-secrets exists and is the right tool when you genuinely need the value, so reach for it deliberately instead of printing secrets by accident.
What is actually pushing you off plain Terraform
What is forcing the decision?
Legal flags the BUSL
OpenTofu
Linux Foundation, MPL, same HCL, same state
State holds plaintext secrets
OpenTofu encryption block
AES-GCM under a KMS key, saved plans covered too
Team wants real code and tests
Pulumi
TypeScript, Python, Go; secrets encrypted by default
Nothing concrete yet
Stay on Terraform
Every skill here transfers; switch when there is a reason
Quick check
01Your state file lives in an S3 bucket with server-side encryption using a customer-managed KMS key (SSE-KMS). An auditor asks whether a leaked read-only credential for that bucket would expose the database password stored in state. What is the honest answer?
Incorrect — Server-side encryption is transparent to authorized callers, which is the entire point of it. S3 decrypts on the way out and hands over plaintext.
Correct — The one wrinkle is that SSE-KMS also needs kms:Decrypt on the key, and key policies are almost always written to cover the same principals that read the bucket. That is exactly the gap OpenTofu's encryption block closes by encrypting inside the process before the bytes are written.
Incorrect — sensitive = true only hides a value in CLI output. State still stores every attribute the provider returned, in plaintext.
Incorrect — Versioning helps you recover from a bad write and has nothing to do with who can read the contents.
02Migrating a repository to OpenTofu, you delete .terraform.lock.hcl, run tofu init, and diff the regenerated lock against the old one. For the same provider version, what should the diff show?
Correct — OpenTofu resolves the shorthand through its own registry, which points at the same signed release assets, so the hashes still match.
Incorrect — OpenTofu does not rebuild providers; it fetches the author's same signed artifacts.
Incorrect — lock entries are keyed by the full provider address including the registry host, which is exactly what changes.
Incorrect — it is the host that moves to registry.opentofu.org while the checksums stay the same.
03You migrate a stack from Terraform 1.5.5 to OpenTofu. The migration should move zero resources, but tofu plan proposes to modify an aws_db_instance instead of printing 'No changes'. What is the right next step to learn whether OpenTofu caused it?
Incorrect — a tool migration should move zero resources, so -auto-approve on an unexplained change is exactly what to avoid.
Correct — the old binary is your control, and a matching plan points at real drift or a provider version that moved when the lock was rewritten.
Incorrect — init does not rebuild state from config, and deleting the backup throws away your only rollback.
Incorrect — going back is unsupported anyway, and a non-empty plan is a signal to investigate, not to reverse.

Do the first switch on the stack you care least about, on a weekday morning, with the pulled state backup sitting on disk. Run tofu init, read the lock file diff line by line, run tofu plan, and stay away from anything that pages people until you have seen the words No changes. If the plan is not empty, the answer is never -auto-approve. It is running plan on the old binary to find out whether the difference came from the tool or from the world.

Try this

Run ls -l ~/state-backups/prod-network.tfstate.bak 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: the migration path runs one way. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related