CoursesOpenTofuState & the plan/apply workflow

State & the plan/apply workflow

init, plan, apply — the same loop.

Beginner12 min · lesson 4 of 12

A shopping list is not a pantry. The list says what should be on the shelves. The shelves hold whatever is actually there. Between the two sits a scrap of paper: the note you wrote on Tuesday saying you already bought the rice, and it went behind the flour. Lose that note and you buy rice again.

OpenTofu works this way too. Your .tf files are the list, written in HCL (HashiCorp Configuration Language, a plain-text format for describing the things you want to exist). Your cloud account is the pantry. The note is a file called state, and OpenTofu keeps it for you. OpenTofu is the open-source fork of Terraform, so the command you type is tofu, and every idea in this lesson has the same shape in any Terraform codebase you meet later.

State is a JSON file (JavaScript Object Notation, a plain-text way of writing structured data) that OpenTofu rewrites after every change. It maps each address in your code, something like aws_instance.web, to the identity of the real object that address created, something like i-0abc123def4567890. That mapping is the whole trick. A cloud API (application programming interface, the remote-control socket a platform exposes over the network) cannot tell you which of your forty running servers came out of your web block. Only state remembers. Delete state and OpenTofu does not delete your servers. It forgets them, then builds a second set next to the first, and you pay for both.

Every job runs the same three commands. tofu init gets the working directory ready. tofu plan works out the difference between your code and reality, then prints it. tofu apply makes that difference real and writes down what happened. Learn this loop properly once and the rest of OpenTofu is variations on it.

Init builds the toolbox, then locks it

tofu init reads your configuration, works out which providers it needs, downloads them, and unpacks them into a hidden .terraform/ directory. A provider is a plugin that speaks one platform's language: Amazon Web Services (AWS), Azure, Cloudflare, your DNS (Domain Name System, the internet's phone book) host. Treat it as a phrasebook. OpenTofu only knows how to say "make me one of these", and the provider knows how to say that to AWS in a way AWS accepts. Run init when you clone a project, and again whenever you change a provider version, add a module (a reusable folder of configuration you call from your own), or point the configuration at a different backend (the place your state lives, either local disk or shared remote storage).

terminal
$ tofu init
output
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.82.2...
- Installed hashicorp/aws v5.82.2 (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!

One line there deserves a defender's attention. (signed, key ID 34365D9472D7468F) means OpenTofu checked a GPG (GNU Privacy Guard, the usual tool for signing files so you can prove who produced them) signature over the release checksums, and then checked the downloaded archive against those checksums, before unpacking anything. Now look at what it unpacked.

terminal
$ ls -l .terraform/providers/registry.opentofu.org/hashicorp/aws/5.82.2/linux_amd64/
output
total 466348
-rw-r--r-- 1 deploy deploy 3049 Dec 19 2024 LICENSE.txt
-rwxr-xr-x 1 deploy deploy 477533016 Dec 19 2024 terraform-provider-aws_v5.82.2_x5

That second file is a program, roughly 470 megabytes of machine code. It runs on your laptop or your build agent, as your user, in a shell that already holds cloud credentials. A tampered provider is code execution with production keys and no exploit required. Read the path it came out of, too: registry.opentofu.org, not Terraform's registry.terraform.io. If your network only lets traffic out to an approved list of hosts, that one has to be on the list or init dies on a configuration that is otherwise perfectly good.

.terraform.lock.hcl is the control that stops provider selection being a coin flip. It records the exact version OpenTofu picked, plus cryptographic checksums of the package it verified. Commit it. Every later tofu init, on any machine, checks the download against those hashes and fails loudly when the bytes differ.

.terraform.lock.hcl
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/hashicorp/aws" {
version = "5.82.2"
constraints = "~> 5.0"
hashes = [
"h1:8Vt9264n1D1eB8cO3E2tGw6Tq0iZ0j0nOFF4qmqYcqc=",
"zh:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
"zh:2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c",
"zh:3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d",
# real files carry one zh: entry per platform in the release
]
}

The h1: entry is a hash of the unpacked provider directory, checked after the archive is expanded. Each zh: entry is a hash of a release zip exactly as the registry published it, one per platform. Releases from the public registry normally carry zh: hashes for every platform, so a mixed team usually gets away with it. The trap is a lock file written against an internal mirror, or by a machine that only ever installed one platform: it carries only that machine's hashes, and then your Linux pipeline fails against a lock file that has only ever seen an Apple silicon laptop. Record them ahead of time with tofu providers lock -platform=linux_amd64 -platform=darwin_arm64. CI (continuous integration, the automated system that builds and checks every change) is where this bug surfaces, always at the worst moment.

The plan is a diff you are meant to read

tofu plan does three things in order. It reads state to learn which real objects it owns. It asks the provider about each of those objects, the refresh step, so the comparison runs against the world as it is now instead of a stale memory. Then it diffs that against your code and prints the actions that would close the gap. Nothing is changed. You can run plan a hundred times against production and break nothing. It does take the state lock while it runs, the way a librarian keeps hold of the ledger while reading it, so nobody else can scribble in it mid-sentence.

terminal
$ tofu plan
output
data.aws_ami.ubuntu: Reading...
aws_s3_bucket_public_access_block.logs: Refreshing state... [id=acme-flowlogs-prod]
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
data.aws_ami.ubuntu: Read complete after 1s [id=ami-04b70fa74e45c3917]
OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
~ update in-place
-/+ destroy and then create replacement
OpenTofu will perform the following actions:
# aws_cloudwatch_log_group.flow will be created
+ resource "aws_cloudwatch_log_group" "flow" {
+ arn = (known after apply)
+ id = (known after apply)
+ log_group_class = (known after apply)
+ name = "/aws/vpc/flowlogs-prod"
+ retention_in_days = 90
+ skip_destroy = false
+ tags_all = (known after apply)
}
# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
~ ami = "ami-0c7217cdde317cfec" -> "ami-04b70fa74e45c3917" # forces replacement
~ arn = "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123def4567890" -> (known after apply)
~ id = "i-0abc123def4567890" -> (known after apply)
~ private_ip = "10.0.1.24" -> (known after apply)
# (33 unchanged attributes hidden)
}
# aws_s3_bucket_public_access_block.logs will be updated in-place
~ resource "aws_s3_bucket_public_access_block" "logs" {
~ block_public_policy = false -> true
id = "acme-flowlogs-prod"
~ restrict_public_buckets = false -> true
# (3 unchanged attributes hidden)
}
Plan: 2 to add, 1 to change, 1 to destroy.
─────────────────────────────────────────────────────────────────────────────
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.

Read the symbols before you read anything else. + creates. ~ changes the thing in place. - destroys. -/+ destroys the existing object and builds a replacement, which on a database or a disk volume takes the data with it. +/- is the same swap in the safer order, build first and destroy second, and you only get it by asking for it with a create_before_destroy lifecycle setting. (known after apply) means the value does not exist yet, because only the cloud can hand it out. The # forces replacement comment names the attribute that triggered the rebuild, and it is the most useful string in the whole output. Check the summary line as well. A replacement counts twice, once as an add and once as a destroy, which is how three changing resources come out as 2 to add, 1 to change, 1 to destroy.

Freeze the plan you actually reviewed

A plan you read on screen and a plan you approve are two different objects unless you save one. That last note in the output above is OpenTofu telling you so in its own words. tofu plan -out=tf.plan writes the decision to a file: the exact set of actions, plus the refreshed state they were worked out against. tofu apply tf.plan then carries out that file. No prompt, no fresh plan, no second opinion. You can also read the file as data with tofu show -json and jq (a command-line tool for pulling fields out of JSON).

terminal
$ tofu plan -out=tf.plan
$ tofu show -json tf.plan | jq -c '.resource_changes[] | {address, actions: .change.actions}'
output
(same diff as above, trimmed here)
Plan: 2 to add, 1 to change, 1 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: tf.plan
To perform exactly these actions, run the following command to apply:
tofu apply "tf.plan"
{"address":"aws_cloudwatch_log_group.flow","actions":["create"]}
{"address":"aws_instance.web","actions":["delete","create"]}
{"address":"aws_s3_bucket_public_access_block.logs","actions":["update"]}

Notice how the replacement shows up as ["delete","create"], in that order, which is the machine-readable version of -/+. This JSON is where automated policy checks hook in. A gate that rejects any plan containing delete on a resource type you marked as holding data, or any security rule opening 0.0.0.0/0 (shorthand for the entire internet), reads exactly these fields. Conftest with Open Policy Agent (OPA, a general-purpose engine for writing rules about structured data) takes this file as input, and so does Checkov.

terminal
$ tofu apply tf.plan
output
aws_instance.web: Destroying... [id=i-0abc123def4567890]
aws_cloudwatch_log_group.flow: Creating...
aws_s3_bucket_public_access_block.logs: Modifying... [id=acme-flowlogs-prod]
aws_cloudwatch_log_group.flow: Creation complete after 1s [id=/aws/vpc/flowlogs-prod]
aws_s3_bucket_public_access_block.logs: Modifications complete after 2s [id=acme-flowlogs-prod]
aws_instance.web: Still destroying... [id=i-0abc123def4567890, 10s elapsed]
aws_instance.web: Destruction complete after 31s
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Creation complete after 22s [id=i-0f9e8d7c6b5a4321f]
Apply complete! Resources: 2 added, 1 changed, 1 destroyed.

Two things to notice in that apply. It never asked for confirmation, because the confirmation already happened when a human read the saved file. And it did not refresh again. It carried out the recorded actions and nothing else. If somebody else applied in between, the saved plan is thrown out rather than quietly merged.

terminal
$ tofu apply tf.plan
output
Error: Saved plan is stale
The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.

That failure is the feature. A stale plan stops the run instead of applying a diff nobody reviewed against a world that has moved on.

A bare tofu apply throws your review away
Run tofu apply with no plan file and OpenTofu builds a brand new plan, shows it, and prompts. That plan can differ from the one you read five minutes ago if a variable, a data source, or the live infrastructure moved in between. In a pipeline it is worse: the diff your reviewer approved on the pull request is not the diff the runner executes, and nothing anywhere connects the two. Always tofu plan -out=tf.plan, then tofu apply tf.plan. Treat -auto-approve as what it is, a switch for applying an unreviewed plan. With a saved plan file you never need it, because applying a file does not prompt in the first place.
One reviewed change, start to finish
1tofu init
providers verified against the lock file
2tofu plan -out=tf.plan
refresh, diff, freeze the decision
3review + policy gate
read the symbols, check show -json
4tofu apply tf.plan
no re-plan, no prompt, stale plan rejected
5state updated
new IDs recorded, serial bumped
Plan refreshes in memory and takes the state lock, but writes nothing. Only apply writes state.

Reading state without opening it

You will read state often and edit it almost never. tofu state list prints every address OpenTofu tracks: your resources, your data sources (read-only lookups, which carry a data. prefix), and everything inside any module you called.

terminal
$ tofu state list
output
data.aws_ami.ubuntu
aws_cloudwatch_log_group.flow
aws_instance.web
aws_s3_bucket.logs
aws_s3_bucket_public_access_block.logs
random_password.db
module.network.aws_subnet.private[0]
module.network.aws_subnet.private[1]
module.network.aws_vpc.this

That listing is your inventory of what OpenTofu believes it owns, and tofu state show <address> prints one entry in full. Both are read-only. Neither so much as touches a real resource, so they are safe to run on production at any hour.

State is a secret store you did not mean to build

Providers write back every attribute the API handed them. For a subnet ID that is dull. For a generated database password, a private key, or an access token, it means the plaintext value is sitting in a file inside a directory you cd into every day. The command line hides it when printing, which is exactly why people miss it.

terminal
$ ls -l terraform.tfstate*
$ tofu state show random_password.db | grep result
$ jq -r '.resources[]
| select(.type == "random_password")
| .instances[0].attributes.result' terraform.tfstate
output
-rw-r--r-- 1 deploy deploy 18452 Jul 21 09:14 terraform.tfstate
-rw-r--r-- 1 deploy deploy 18310 Jul 21 09:12 terraform.tfstate.backup
result = (sensitive value)
qT4%wZ8pLm2!vB6nRj0#

(sensitive value) is a display rule, not encryption. The provider marks that attribute sensitive in its schema, so the CLI (command-line interface, the tofu program you type at) blanks it on screen, and state also carries a sensitive_attributes list for values your own configuration marked sensitive. Neither one encrypts a single byte. The file holds the real string, and a fresh local state file lands world-readable at mode 0644, because OpenTofu creates it 0666 and the usual umask (the mask that strips permission bits off every new file, normally 022) takes the write bits off and leaves the read bits on. Its sibling terraform.tfstate.backup, holding the previous copy, gets the same treatment.

The saved plan file belongs in the same category and people forget it constantly. tf.plan is a compressed archive, not a text diff. It carries the prior state and every planned attribute value, secrets included, and tofu show tf.plan will print them straight back out. Pipelines love to keep plan files as build artifacts the whole company can download, and review bots love to paste them into pull request comments. Treat a plan file exactly like state: short-lived, access-controlled, never in git, never in a chat channel.

So the rules are short. Never commit state. Never hand-edit it. Never paste it into a ticket. On a shared build host, keep the working directory somewhere other users cannot read, and chmod 600 both state files, which leaves read and write for the owner and nothing at all for anyone else. The one artefact in that directory you do want in git is the lock file, which is why the ignore file below says so out loud. How teams keep state shared, locked and encrypted instead of sitting on one laptop is the job of backends and state encryption, in ot-remote and ot-encrypt. OpenTofu's state encryption, added in version 1.7, covers plan files as well as state files.

.gitignore
# working directory, provider cache, backend scratch files
.terraform*
# except this one: it pins provider versions and checksums, so commit it
!.terraform.lock.hcl
# state, and the automatic backup copy of state
*.tfstate
*.tfstate.*
# saved plans hold the same secrets as state
*.tfplan
tf.plan
# variable files often carry credentials
*.tfvars
*.tfvars.json
crash.log
crash.*.log

Drift is a signal, not an annoyance

Somebody opens the web console at 2am to reach a box during an incident, widens the SSH (Secure Shell, the standard encrypted remote login protocol) rule on port 22 from the office range to 0.0.0.0/0, and never puts it back. It is the spare key left under the mat after a callout. Reality has now moved away from both your code and your state, and OpenTofu calls that gap drift. tofu plan -refresh-only goes looking for it: re-read every tracked object, report what changed, propose nothing.

terminal
$ tofu plan -refresh-only
output
aws_vpc_security_group_ingress_rule.ssh: Refreshing state... [id=sgr-0d4e5f6a7b8c9d0e1]
aws_instance.web: Refreshing state... [id=i-0f9e8d7c6b5a4321f]
Note: Objects have changed outside of OpenTofu
OpenTofu detected the following changes made outside of OpenTofu since the
last "tofu apply" which may have affected this plan:
# aws_vpc_security_group_ingress_rule.ssh has been changed
~ resource "aws_vpc_security_group_ingress_rule" "ssh" {
~ cidr_ipv4 = "10.0.0.0/8" -> "0.0.0.0/0"
id = "sgr-0d4e5f6a7b8c9d0e1"
# (9 unchanged attributes hidden)
}
This is a refresh-only plan, so OpenTofu will not take any actions to undo
these. If you were expecting these changes then you can apply this plan to
record the updated values in the OpenTofu state without changing any real
infrastructure.

A plain tofu plan would offer to put that rule straight back, which is usually the right answer and occasionally a very bad one during a live incident. Refresh-only splits the question in two: what moved, and separately, do we undo it or accept it. When the change was legitimate, tofu apply -refresh-only records reality into state without touching a single resource.

This is the piece you can schedule. -detailed-exitcode replaces the usual pass or fail codes with three meaningful ones: 0 for no differences, 2 for differences found, 1 for a run that failed outright. A short script plus a systemd timer (the scheduler built into modern Linux, the successor to cron) turns that into an alert somebody actually sees the next morning.

/usr/local/bin/tofu-drift
#!/usr/bin/env bash
set -euo pipefail
umask 077 # the saved output can contain secrets: owner-only, always
OUT=/var/lib/tofu-drift/plan.out
cd /srv/infra/prod
tofu init -input=false -lock-timeout=5m >/dev/null
# -detailed-exitcode: 0 = no drift, 2 = drift found, 1 = the run itself failed
rc=0
tofu plan -refresh-only -detailed-exitcode -input=false -no-color >"$OUT" 2>&1 || rc=$?
case "$rc" in
0) logger -t tofu-drift -p daemon.info "no drift" ;;
2) logger -t tofu-drift -p daemon.warning \
"DRIFT: $(grep -c 'has been changed' "$OUT") object(s) changed outside OpenTofu" ;;
*) logger -t tofu-drift -p daemon.err "drift check failed (rc=$rc); see $OUT" ;;
esac
exit 0
/etc/systemd/system/tofu-drift.service
[Unit]
Description=OpenTofu drift check (prod)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=deploy
Group=deploy
WorkingDirectory=/srv/infra/prod
Environment=TF_IN_AUTOMATION=1
ExecStart=/usr/local/bin/tofu-drift
TimeoutStartSec=30min
# /var/lib/tofu-drift, created as deploy:deploy and writable without
# widening ProtectSystem. RuntimeDirectory would be deleted the moment
# this oneshot exits, taking the output file with it.
StateDirectory=tofu-drift
StateDirectoryMode=0700
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/srv/infra/prod
/etc/systemd/system/tofu-drift.timer
[Unit]
Description=Nightly OpenTofu drift check
[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.target

ProtectSystem=strict makes the whole filesystem read-only apart from what you name, which is why /srv/infra/prod is listed explicitly: OpenTofu writes state and the .terraform cache there. ProtectHome=read-only costs you nothing as long as the box gets its cloud credentials from an instance role rather than a key file in the deploy user's home directory, which is how it should be getting them anyway.

terminal
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now tofu-drift.timer
$ systemctl list-timers tofu-drift.timer
output
Created symlink /etc/systemd/system/timers.target.wants/tofu-drift.timer → /etc/systemd/system/tofu-drift.timer.
NEXT LEFT LAST PASSED UNIT ACTIVATES
Wed 2026-07-22 03:19:44 UTC 17h 58min - - tofu-drift.timer tofu-drift.service
1 timers listed.
Pass --all to see loaded but inactive timers, too.

The next morning, journalctl -t tofu-drift -n 20 --no-pager shows the verdict, and a drift night reads like DRIFT: 1 object(s) changed outside OpenTofu. Exit code 2 on a production stack at 3am is worth a page, not a shrug. It means production changed outside the reviewed path, and you now have a timestamp to start an investigation from.

One kind of change no drift check will catch on its own: tofu state rm takes a resource out of state and destroys nothing. The machine keeps running, keeps billing, keeps its data, and OpenTofu stops looking at it forever. Sometimes that is exactly what a migration needs. It is also the quietest way to move a production box outside every review, alarm and drift check you built, and it needs no permission on the machine itself, only the ability to run tofu and write to wherever state is kept. So compare tofu state list against the platform's own inventory on a schedule, and treat anything that exists in the cloud but not in state as a question that needs an answer. When you do need to restructure state, prefer a moved block in your code over tofu state mv typed at a keyboard: the block goes through review and leaves a record in git, the command leaves nothing behind but your memory of running it.

What a pipeline actually runs

The safe pipeline falls out of everything above, and it is five commands with a little bookkeeping around the exit code.

pipeline.sh
#!/usr/bin/env bash
set -euo pipefail
# --- review stage, on the pull request ---
tofu init -input=false -lock-timeout=5m
rc=0
tofu plan -input=false -lock-timeout=5m -out=tf.plan -detailed-exitcode || rc=$?
case "$rc" in
0) echo "no changes; nothing to deploy"; exit 0 ;;
2) : ;; # changes to review, keep going
*) echo "plan failed" >&2; exit "$rc" ;;
esac
tofu show -json tf.plan > tf.plan.json
conftest test --policy policy/ tf.plan.json
# --- deploy stage, only after a human approved THAT file ---
tofu apply -input=false -lock-timeout=5m tf.plan

-input=false stops a run hanging forever on a variable prompt nobody is there to answer. -lock-timeout=5m waits politely for a busy state lock instead of failing the second another run holds it. -detailed-exitcode is the flag that trips people: it returns 2 whenever the plan has changes, and a build runner reading any non-zero code calls that a failed build, so you handle it explicitly as above. The apply stage takes a file rather than a directory, so the only thing it can possibly do is the thing that was reviewed. That file has to travel from the review stage to the deploy stage, which does make it a build artifact, so give it the shortest retention your system allows and restrict it to the people who could already deploy.

Quick check
01Your pipeline runs tofu plan on the pull request so a reviewer can read the diff, then runs tofu apply -auto-approve after merge. Both runs succeed. What is the real risk?
Incorrect — Nothing links the two runs. They are separate processes, minutes or hours apart, sharing only a directory.
Correct — A bare apply re-plans from scratch, and -auto-approve removes the last human checkpoint. Save the plan with -out and apply that file.
Incorrect — A bare apply is perfectly valid. It re-plans and, with -auto-approve, skips the prompt. That is the problem, not an error.
Incorrect — Generating two plans harms nothing on its own. The risk is that only the first one was ever read by a person.
02tofu state show random_password.db prints result = (sensitive value). What does that tell you about how the password is actually stored in terraform.tfstate?
Incorrect — (sensitive value) is not encryption; this marker encrypts nothing in a plain state file.
Incorrect — The provider writes the real value back, not a hash, and the lesson recovers it directly with jq.
Incorrect — It is written to the file; the display rule only hides it on screen.
Correct — (sensitive value) blanks the terminal output only; the lesson shows jq printing the actual string straight out of the file.
03A tofu plan for production shows -/+ resource "aws_db_instance" "main" with # forces replacement on one attribute, and the summary reads Plan: 1 to add, 0 to change, 1 to destroy. What must you understand before applying?
Correct — -/+ is destroy-then-create, and the lesson warns that on a database or disk volume the replacement takes the data with it; it also shows as one add plus one destroy.
Incorrect — That safer order is +/-, and you only get it by asking for it with a create_before_destroy lifecycle setting.
Incorrect — A replacement counts twice for the same resource, once as the add and once as the destroy; no second resource is involved.
Incorrect — That comment names the attribute whose change forces the rebuild; it is the cause of the -/+, not a mere note.

Before you touch state in any way, take a copy. Run umask 077 first, then tofu state pull > /var/backups/tofu/prod-$(date -u +%Y%m%dT%H%M%SZ).json, which writes the current state to a file whatever backend you use. Look at the serial number inside it, a counter that goes up on every write. If a move or a removal goes wrong, tofu state push of that snapshot puts you back where you were, and it will refuse to overwrite a newer serial unless you add -force, which is a guard rail rather than an obstacle. Guard the copy exactly like the real thing, because it holds the same passwords, and remove it once the job is done.

Try this

Run ls -l .terraform/providers/registry.opentofu.org/hashicorp/aws/5.82.2/linux_amd64/ 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 bare tofu apply throws your review away. 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