CoursesTerraformThe workflow: init, plan, apply, destroy

The workflow: init, plan, apply, destroy

Change infrastructure safely.

Beginner12 min · lesson 4 of 15

A good builder does not start knocking through walls the moment you hand over the keys. They walk the house, write up a quote listing every wall to be moved and every pipe to be rerouted, then wait for your signature. Terraform runs on the same contract. Every change to real infrastructure goes through a written quote you read first, and nothing touches a single server until you approve it.

The cycle is four commands. terraform init sets up the working directory: it downloads the provider plugins (adapters that turn your configuration into calls to a cloud's API, the programmatic front door that AWS, Azure or Kubernetes puts behind everything you can click in a console) and connects to the backend, wherever Terraform keeps its state file, its own ledger of what it has built. terraform plan works out the difference between your code and reality and prints it, changing nothing at all. terraform apply carries out that difference after showing it to you again and asking you to confirm. terraform destroy removes everything the configuration manages. The gate between plan and apply is the whole safety story. You never change what you have not read.

The loop you run every time
1init
providers and modules, checked against the lock file
2plan -out=tfplan
read state, ask the live API, print the diff
3review
destroys, replacements, open ports, wildcard permissions
4apply tfplan
runs exactly the reviewed actions, nothing else
5watch for drift
refresh-only plan on a timer, alarm on exit code 2
Only one step in this list changes anything, and you can force it to run nothing except what you already read.

What init actually does

init is unpacking the toolbox before the job starts. Run it in a directory and it installs the providers your configuration asks for into a local .terraform/ folder, writes or verifies the lock file, wires up the backend (the place your state lives, commonly an S3 bucket, Amazon's object storage), and fetches any modules you reference. It changes no infrastructure, so you can run it as often as you like. You have to rerun it whenever you add a provider, bump a version constraint, change a module source, or change backend settings, and Terraform refuses to plan until you do. Where backend settings differ per environment, you feed them in at this step with terraform init -backend-config=prod.s3.tfbackend.

terminal
$ cd /srv/infra/prod
$ terraform init # safe to rerun; touches no infrastructure
output
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.60"...
- Installing hashicorp/aws v5.62.0...
- Installed hashicorp/aws v5.62.0 (signed by HashiCorp)
Terraform 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 Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!

That lock file is the part worth caring about as a defender. It is a receipt with serial numbers on it. For every provider it records the exact version chosen and a list of hashes, which are cryptographic fingerprints: short strings that change completely if a single byte of the file changes. The h1: line fingerprints the unpacked provider directory. Each zh: line fingerprints one official release archive, one per platform. On the next init, Terraform recomputes the fingerprint of whatever it just downloaded, compares it with the receipt, and stops with an error if the two disagree.

Now think about what a provider binary is. It is a program that runs on a machine holding cloud administrator credentials, and it makes API calls on your behalf. A swapped binary from a poisoned registry mirror or a meddling proxy owns your entire account. The lock file is the check that catches the swap, with one honest limit: it can only compare against what was recorded the first time, so the commit that first introduces a provider is the one that deserves the careful read. Commit the file. If your team runs Terraform on both Linux and macOS, record hashes for every platform in one go with terraform providers lock -platform=linux_amd64 -platform=darwin_arm64, otherwise a colleague's init fails on a missing hash and somebody will be tempted to delete the file to make the error go away. Changing versions is a deliberate act: terraform init -upgrade rewrites the lock, so a modified lock file in a pull request deserves a proper look.

.terraform.lock.hcl
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "5.62.0"
constraints = "~> 5.60"
hashes = [
"h1:6Sn0dFsQwZ2SLZTNPnhVzZzEHQNQ6Ce1IHWQAIzB1WI=",
"zh:1e4d0b4b3f9a5c6d8e2f0a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d",
"zh:2f5e1c6c4a0b6d7e9f3a1b8c0d2e4f6a8b0c2d4e6f8a0b2c4d6e8f0a2b4c6d8e",
"zh:3a6f2d7d5b1c7e8f0a4b2c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f",
]
}

Plan is the written quote

plan does three things in order. It reads state, which is Terraform's memory of what it created and the real identifiers those resources were given. It refreshes that memory by asking the live APIs what each of those resources looks like right now. Then it compares the refreshed picture against your code and prints the difference. No resource is created, changed or deleted. You can run plan against production on a Tuesday afternoon and the worst you do is make a pile of read-only API calls. One caveat: plan takes the state lock by default, so a slow plan can leave a colleague's apply waiting behind it. Pass -lock=false when the run is only ever going to look.

Every line in the diff carries a symbol, and those symbols are the vocabulary you need. Plus means create. Tilde means update in place. Minus means destroy. The pair -/+ means destroy the old resource and then create its replacement. The reverse pair +/- means create the replacement first and destroy the old one afterwards, which is what a resource with create_before_destroy set does. And <= means read, so a data source is being fetched.

terminal
$ terraform plan # a dry run: nothing is created, changed or deleted
output
aws_security_group.web_sg: Refreshing state... [id=sg-0f2b1c3d4e5f6a7b8]
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
aws_db_instance.main: Refreshing state... [id=db-5R2QZK3XN7HVWMEXAMPLE4TZQY]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
~ update in-place
-/+ destroy and then create replacement
Terraform will perform the following actions:
# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0abc123def4567890"
~ instance_type = "t3.micro" -> "t3.small"
# (28 unchanged attributes hidden)
}
# aws_security_group.web_sg will be updated in-place
~ resource "aws_security_group" "web_sg" {
id = "sg-0f2b1c3d4e5f6a7b8"
~ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 22
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 22
},
# (1 unchanged element hidden)
]
# (8 unchanged attributes hidden)
}
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ address = "prod-db.cvv0ex4mple.eu-west-1.rds.amazonaws.com" -> (known after apply)
~ db_name = "app" -> "appdb" # forces replacement
~ id = "db-5R2QZK3XN7HVWMEXAMPLE4TZQY" -> (known after apply)
identifier = "prod-db"
# (47 unchanged attributes hidden)
}
Plan: 1 to add, 2 to change, 1 to destroy.
───────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Read that output back to front. The summary line is your headline check: 1 to add, 2 to change, 1 to destroy. The add and the destroy are the same database counted twice, because a replacement is a delete followed by a create. Then go looking for the comment Terraform writes beside the attribute responsible: db_name "app" becoming "appdb", tagged forces replacement. Somebody renamed a database in code. The provider has no API call that renames one in place, so the only route to the state you asked for is to delete the instance and build a new empty one. The (known after apply) markers are values the cloud will hand out later, and (47 unchanged attributes hidden) is Terraform keeping the diff readable, not hiding anything from you. Notice the last note as well: Terraform is telling you in its own words that a plan you did not save is not a promise. If a plan proposes a destroy or a replace you did not intend, stop, fix the code, and plan again.

-/+ means it deletes first
A replacement is the most dangerous line in any plan. For a database, a disk, an object store or anything else holding data, -/+ means the existing resource is deleted, its contents go with it, and a fresh empty one appears in its place. The old resource is gone before the new one exists, so you get an outage on top of the data loss. It happens whenever you change an argument the provider cannot update in place. Scan every plan for - and -/+ before you approve it. For anything that stores data, fit the brake in advance with lifecycle { prevent_destroy = true } and handle the change as a planned migration, never as an automatic apply that runs while you sleep.

Reading a plan like a security reviewer

A plan is a security document, and the one above contains a genuine incident waiting to happen. Somebody added an ingress rule allowing port 22, which is SSH (Secure Shell, the remote login protocol), from 0.0.0.0/0. That is a CIDR block, a range of addresses written as a base address plus a prefix length, and this particular one covers every address on the internet. Nothing in the output shouts about it. It reads like any other in-place update, a few lines below a harmless instance resize. Three categories are worth scanning on every review: anything destroyed or replaced that holds data, any network rule getting wider, and any identity change, meaning an IAM (Identity and Access Management, the service that decides who is allowed to call what) policy that gains a star in its actions or resources, or a role trust policy that gains a new principal. Those three cover most of the ways a routine Tuesday change turns into a Wednesday incident.

Human eyes miss things at three in the afternoon, so make the machine look too. terraform show -json turns a saved plan into structured data, and that is exactly how policy tools such as Conftest, OPA (Open Policy Agent) and Checkov gate a pipeline. Here are the same two checks done by hand with jq, a command line tool for querying JSON (JavaScript Object Notation, the plain text data format the plan is exported in), so you can see what those tools see.

terminal
$ terraform plan -out=tfplan -detailed-exitcode -no-color > plan.txt ; echo "exit=$?"
$ terraform show -json tfplan > plan.json
# 1. anything being deleted (a replacement shows up as delete + create)
$ jq -r '.resource_changes[]
| select(.change.actions | index("delete"))
| "\(.change.actions | join("+")) \(.address)"' plan.json
# 2. any rule this plan would open to the whole internet
$ jq -r '.resource_changes[].change.after.ingress[]?
| select(any(.cidr_blocks[]?; . == "0.0.0.0/0"))
| "OPEN \(.from_port)-\(.to_port)/\(.protocol) to the internet"' plan.json
output
exit=2
delete+create aws_db_instance.main
OPEN 22-22/tcp to the internet

One OPEN line, not two, because the security group's other rule allows 443 from the load balancer's own security group rather than from a CIDR block, so it never matches the filter. That is the shape of a good check: it stays quiet on the rules you meant to have and speaks up on the one you did not. The exit code is the other half of the trick. An exit code is the small number every command hands back when it finishes, and a shell or a service manager can read it without understanding a word of the output. With -detailed-exitcode, plan returns 0 when nothing would change, 1 when it errored, and 2 when there are changes to make. A shell script, a pipeline step or a systemd unit can act on that without parsing any human text, which is what turns plan from something a person types into something a machine watches.

Apply what you actually read

Run terraform apply with no arguments and it recomputes the plan, prints it, and asks: Do you want to perform these actions? Only 'yes' will be accepted to approve. Typing yes is your signature on the quote. Apply also takes a lock on the state file first, on any backend that supports locking, so two people cannot apply at once. The lock records who holds it and since when, which is a small but real audit trail when a pipeline hangs at midnight.

There is a gap in that flow, though, and it is the one that both attackers and accidents live in. The plan a reviewer approved on a pull request at 10am is not the plan that apply computes at 2pm. Between those two moments another branch merged, or somebody clicked something in the console. terraform plan -out=tfplan writes the exact set of actions to a file, and terraform apply tfplan performs precisely those actions, with no recomputation and no prompt. Reviewed artifact in, same artifact out. Terraform will also refuse a saved plan whose state has moved on, with the error "Saved plan is stale", but be precise about what that check covers: it compares state, so it catches another Terraform run writing state, not a human clicking around the console. That second case is drift, and it is the last section of this lesson.

terminal
$ terraform plan -out=tfplan # a fresh stack: the reviewed artifact
$ terraform apply tfplan # runs exactly that, no second prompt
output
Plan: 2 to add, 0 to change, 0 to destroy.
───────────────────────────────────────────────────────────────────────────
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"
aws_security_group.web_sg: Creating...
aws_security_group.web_sg: Creation complete after 2s [id=sg-0f2b1c3d4e5f6a7b8]
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Still creating... [20s elapsed]
aws_instance.web: Still creating... [30s elapsed]
aws_instance.web: Creation complete after 33s [id=i-0abc123def4567890]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

One thing about that file before you put it in a pipeline. tfplan is not encrypted and it is not redacted. It carries a copy of prior state plus the resolved values of your variables and resource attributes, database passwords and private keys included, even ones marked sensitive = true, because that flag only hides them from terminal output. Publish tfplan as a build artifact, attach it to a ticket, or leave it in a world-readable workspace, and you have handed those values to everybody who can read the pipeline. Keep plan files inside the job that created them, on storage with tight permissions, and delete them once the apply is done. If you need to circulate the review, circulate terraform show tfplan output that you have read yourself, not the file.

Destroy, and the brakes you fit first

destroy is apply pointed the other way. It is an alias for terraform apply -destroy: it builds a plan in which every managed resource is deleted, shows it, and asks for confirmation in wording deliberately blunter than the apply prompt. It only removes resources this configuration manages, so a hand-made bucket in the same account survives, but everything in state goes. The plan it prints puts -> null beside every attribute of every doomed resource, which for one EC2 instance runs to roughly forty lines, so the parts to actually read are the header line above each resource and the count at the bottom.

Two habits keep this boring. Keep environments in separate directories with separate state and separate credentials, so the role you use in dev has no permission to touch prod at all. Workspaces are the weaker version of that fence: they share one backend and one set of credentials, so a mistyped terraform workspace select is all that stands between you and the wrong environment. And resist -target=..., which limits an operation to a single resource. It exists for repairing a broken state, Terraform prints a warning every time you use it, and treating it as a routine tool leaves you applying pieces of a plan that nobody reviewed as a whole.

terminal
$ cd /srv/infra/dev
$ terraform destroy
output
aws_instance.web: Refreshing state... [id=i-05f9c2a1b7d3e8046]
aws_security_group.web_sg: Refreshing state... [id=sg-0a1b2c3d4e5f60718]
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# aws_instance.web will be destroyed
- resource "aws_instance" "web" {
- ami = "ami-0c1bc246476a5572b" -> null
- availability_zone = "eu-west-1a" -> null
- id = "i-05f9c2a1b7d3e8046" -> null
- instance_type = "t3.micro" -> null
- private_ip = "10.0.1.24" -> null
- subnet_id = "subnet-08e3a7c1d9b204f6a" -> null
- tags = {
- "Name" = "dev-web"
} -> null
}
# aws_security_group.web_sg will be destroyed
- resource "aws_security_group" "web_sg" {
- id = "sg-0a1b2c3d4e5f60718" -> null
- name = "dev-web-sg" -> null
- vpc_id = "vpc-0d41f8a2c6b73e915" -> null
}
Plan: 0 to add, 0 to change, 2 to destroy.
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
aws_instance.web: Destroying... [id=i-05f9c2a1b7d3e8046]
aws_instance.web: Still destroying... [id=i-05f9c2a1b7d3e8046, 10s elapsed]
aws_instance.web: Still destroying... [id=i-05f9c2a1b7d3e8046, 20s elapsed]
aws_instance.web: Still destroying... [id=i-05f9c2a1b7d3e8046, 30s elapsed]
aws_instance.web: Destruction complete after 32s
aws_security_group.web_sg: Destroying... [id=sg-0a1b2c3d4e5f60718]
aws_security_group.web_sg: Destruction complete after 1s
Destroy complete! Resources: 2 destroyed.

For the resources you can never afford to lose, put the brake in the code itself. prevent_destroy is a lifecycle setting that makes Terraform fail at plan time, before any prompt appears, if the plan would delete that resource. Failing early matters: the error arrives while you are still reading, not halfway through an apply with the database already gone. It also stops an unattended pipeline dead, which is exactly what you want at two in the morning. Two limits are worth knowing. The value has to be a literal true, because lifecycle settings cannot read variables. And the guard lives inside the resource block, so deleting that block from your configuration deletes the guard with it, and the next apply will cheerfully destroy the resource. Removing a protected resource from code deserves the same scrutiny as dropping a production database, because that is what it is.

/srv/infra/prod/main.tf
resource "aws_db_instance" "main" {
identifier = "prod-db"
engine = "postgres"
engine_version = "16.3"
db_name = "app" # changing this forces replacement
instance_class = "db.t4g.small"
allocated_storage = 20
storage_encrypted = true
username = "appadmin"
manage_master_user_password = true # password lives in Secrets Manager
lifecycle {
prevent_destroy = true # any plan that deletes this fails outright
}
}
terminal
$ cd /srv/infra/prod
$ terraform destroy
output
aws_security_group.web_sg: Refreshing state... [id=sg-0f2b1c3d4e5f6a7b8]
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
aws_db_instance.main: Refreshing state... [id=db-5R2QZK3XN7HVWMEXAMPLE4TZQY]
│ Error: Instance cannot be destroyed
│ on main.tf line 1:
│ 1: resource "aws_db_instance" "main" {
│ Resource aws_db_instance.main has lifecycle.prevent_destroy set, but the
│ plan calls for this resource to be destroyed. To avoid this error and
│ continue with the plan, either disable lifecycle.prevent_destroy or reduce
│ the scope of the plan using the -target option.

Drift: turning plan into a detector

Here is the payoff most teams never collect. Because plan compares your code against what the API actually reports, a plan run when nobody has touched the code tells you whether anybody has touched the cloud by hand. That difference is called drift, and drift is a detection signal. Somebody with stolen console credentials does not edit your repository. They open a port, attach a policy or add an access key straight in the provider, and your next plan quietly proposes to undo it.

Know the edge of that detector, though, because it is easy to oversell. plan only sees resources Terraform already manages. A brand new security group the attacker created out of nothing is invisible to it, because that resource is not in state and your code never mentioned it. Drift catches tampering with what you own; catching the rest is the job of your cloud audit log and threat detection. Within its range it is excellent, and terraform plan -refresh-only narrows the question on purpose: what has changed out there since my last apply, ignoring any edits I have made to the code since.

terminal
$ terraform plan -refresh-only -detailed-exitcode -input=false
$ echo "exit=$?"
output
aws_security_group.web_sg: Refreshing state... [id=sg-0f2b1c3d4e5f6a7b8]
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
Note: Objects have changed outside of Terraform
Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:
# aws_security_group.web_sg has changed
~ resource "aws_security_group" "web_sg" {
id = "sg-0f2b1c3d4e5f6a7b8"
~ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 22
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 22
},
# (1 unchanged element hidden)
]
# (8 unchanged attributes hidden)
}
This is a refresh-only plan, so Terraform 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 Terraform state without changing any remote
objects.
exit=2

Read the last paragraph of that output carefully, because the two ways forward are opposites. terraform apply -refresh-only accepts the drift: it writes the new reality into state and changes nothing in the cloud, which is what you want when the change was legitimate and you are about to write it into the code. A plain terraform apply does the reverse. Your code is still the source of truth, so it strips that ingress rule back out and puts the resource back the way it is written. When the change was hostile, that plain apply is the fastest and most reliable rollback you own, and it fixes every drifted resource at once instead of one console click at a time.

Run the drift check on a timer

A detector you have to remember to run is not a detector. Put it on a systemd timer, the alarm clock built into every modern Linux box (systemd is the program that starts and supervises services, and a timer unit is its equivalent of a cron entry, a job the machine runs on a schedule with nobody logged in). Run it on the host that already holds the credentials and the initialised working directory. Type=oneshot means the unit runs the command once and adopts the command's exit code as its own verdict, and with no SuccessExitStatus line the default applies: 0 is success, anything else marks the unit failed. A refresh-only plan counts detected drift as something worth applying, so -detailed-exitcode returns 2 the moment a managed resource has moved. The unit goes red, and a red unit is something your existing monitoring already knows how to notice. Give that timer read-only cloud credentials while you are at it. The run never writes state and never changes infrastructure, so it has no business holding a role that could.

/etc/systemd/system/tf-drift.service
[Unit]
Description=Terraform drift check (prod)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=terraform
WorkingDirectory=/srv/infra/prod
Environment=TF_IN_AUTOMATION=1
SyslogIdentifier=tf-drift
# credentials come from the instance profile, and that role is read-only
# -lock=false: this run never writes state, so it must not block a real apply
# exit 2 = drift detected = oneshot unit enters the failed state
ExecStart=/usr/bin/terraform plan -refresh-only -detailed-exitcode \
-input=false -lock=false -no-color
/etc/systemd/system/tf-drift.timer
[Unit]
Description=Nightly Terraform drift check
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
terminal
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now tf-drift.timer
$ systemctl list-timers tf-drift.timer
output
Created symlink /etc/systemd/system/timers.target.wants/tf-drift.timer → /etc/systemd/system/tf-drift.timer.
NEXT LEFT LAST PASSED UNIT ACTIVATES
Wed 2026-07-22 02:30:00 UTC 15h left - - tf-drift.timer tf-drift.service
1 timers listed.
Pass --all to see loaded but inactive timers, too.
Quick check
01Your pipeline runs terraform plan on every pull request, a reviewer reads the diff and approves the merge, and the merge job then runs terraform apply -auto-approve with no plan file. What is the real weakness?
Incorrect — apply still takes the state lock on any backend that supports locking. -auto-approve only skips the typed yes.
Correct — anything that changed between review and merge, another merge or a console click, lands silently in the plan that actually runs.
Incorrect — plan marks replacements with -/+ and names the attribute that forces them.
Incorrect — plan refreshes from the live API whether or not you save the result to a file.
02Which statement about terraform init matches the lesson?
Incorrect — init changes no infrastructure; it stages providers, modules and the backend and is safe to rerun any time.
Incorrect — Terraform refuses to plan until you re-init after adding a provider or changing a version, module source or backend.
Correct — init only sets up the working directory, yet those four changes require a fresh init before Terraform will plan.
Incorrect — refreshing resource state against the live API is the refresh step of plan, not what init does.
03You protected the production database with lifecycle { prevent_destroy = true }. A teammate opens a pull request that deletes the entire aws_db_instance.main resource block to "retire" it. What does the next apply do, and why?
Incorrect — the guard lives inside the resource block, so removing the block removes the guard along with it.
Incorrect — prevent_destroy does not detach anything; with the guard gone, apply plans and performs a normal destroy.
Incorrect — no such step exists; once the block is deleted the guard is already gone.
Correct — the safeguard only exists as long as the block does, so removing the block from code lets the next apply tear the database down.

When that unit does go red, you have a short repeatable path. Run journalctl -u tf-drift.service -n 50 --no-pager to see which resource moved and which attribute changed. Take the resource identifier to your provider's audit log (CloudTrail on AWS, Activity Log on Azure), find the API call that made the change, and read off who signed it and from which address. If the change was legitimate, write it into the code and apply, so state and reality agree again. If it was not, a plain terraform apply in that directory puts every drifted resource back to what the reviewed code says, and you still have the audit log entry naming the credential you now need to revoke.

Try this

Run terraform init # safe to rerun; touches no infrastructure 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: -/+ means it deletes first. 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