plan, apply & destroy

The Terraform workflow, safely.

Beginner12 min · lesson 6 of 23

A good builder hands you an itemised quote before the first wall comes down. These three walls go, this beam gets replaced, the kitchen stays. You read it, you argue about the beam, and only then do you sign. Terraform is built around the same courtesy. Every change to real infrastructure goes through a written estimate you can read, question, and refuse before anything moves. That estimate is called the plan, and learning to read one properly is most of what separates someone who can run Terraform from someone you would let near production.

The whole workflow is four commands. terraform init gets the working directory ready: it downloads the providers (plugins that know how to talk to one platform's API, the machine-facing interface a service like AWS or Cloudflare exposes) and wires up the backend (wherever the state file lives, on your disk or in a bucket the team shares). State is Terraform's ledger: what it built, and the identifier the cloud handed back for each thing. terraform plan compares what you wrote against what already exists and prints the difference, touching nothing. terraform apply carries out a plan. terraform destroy removes everything that configuration manages. Of the four, only plan leaves your cloud resources completely alone. init writes to your working directory. The other two rearrange the real world, and one of them does it by deleting.

Init: Fetch the Tools, Then Pin Them

Run init once in a new directory, then again any time you add a provider, change a version constraint, or point at a different backend. It creates a .terraform/ directory holding the downloaded plugin binaries, records the backend settings, and writes a lock file. Nothing in your cloud account changes. Do not read that as harmless, though. init fetches third-party code that the repository chooses for you: providers from a registry, modules from Git (a version control system) or a plain web URL. That code runs on your machine, with your credentials, the moment you plan. On a repository you did not write, read the required_providers block and every module source line before you run anything.

terminal
$ cd /srv/infra/prod
$ terraform init
output
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- 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!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure.
.terraform.lock.hcl
provider "registry.terraform.io/hashicorp/aws" {
version = "5.62.0" # the exact version chosen
constraints = "~> 5.0" # what your code asked for
hashes = [
"h1:2vLpEs5f5zTQBfCr1cxrPCV1lgrGRxbSPHNQ0m8gsGE=", # the unpacked package, one platform
"zh:0e0a1a3b4c1e0ea9b3f4c0d9f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4", # a release zip
# ...one zh: entry per platform in the release
]
}

Treat that lock file as a supply-chain control, because that is exactly what it is. It is the part number and batch stamped on every component you were sent: the precise provider versions Terraform chose, plus checksums (short fingerprints computed from the file contents) of the packages it downloaded. Commit it. Whenever init runs on a machine that does not already have that provider cached, Terraform downloads it and checks the package against those hashes, and a mismatch stops the run cold. A provider is code that executes with your cloud credentials, so a swapped or tampered package is close to the worst day you can have. If your build agents run a different operating system or processor architecture than your laptop, record their hashes too, with terraform providers lock -platform=linux_amd64 -platform=darwin_arm64. Otherwise the pipeline hits a missing-hash error and somebody "fixes" it by deleting the lock file.

One change, start to finish
1edit the .tf files
the change you want, written down
2terraform init
providers, backend, lock file
3terraform plan -out=tfplan
the estimate, saved to a file
4review the diff
hunt for "must be replaced" and "will be destroyed"
5terraform apply tfplan
runs that exact file, no second prompt
6terraform plan
expect: No changes
The two read-only steps in the middle are what guard every write. Drop the -out and apply builds a fresh plan that nobody reviewed.

The Plan Is an Estimate You Can Save

A plan is a three-way comparison. Your code says what should exist. The state file says what Terraform believes it built last time. The provider asks the live API what is actually out there right now. Terraform refreshes state against reality first, lines all three up, and the leftovers are the plan. Think of a shopping list, last week's receipt, and the real contents of the fridge. Any one of them on its own will lie to you. The useful answer lives in the gaps between them. (You can skip the refresh with -refresh=false when the API is slow or rate-limiting you, at the cost of planning against a stale picture.)

terminal
$ terraform fmt -check -recursive # formatting drift? exits non-zero if so
$ terraform validate # syntax and type checks, no cloud calls
$ terraform plan -out=tfplan # the estimate, written to a file
output
Success! The configuration is valid.
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0e2c8caa4b6378d8c"
+ instance_type = "t3.micro"
+ id = (known after apply)
+ private_ip = (known after apply)
+ vpc_security_group_ids = (known after apply)
+ tags = {
+ "Name" = "web-server"
}
}
# aws_security_group.web_sg will be created
+ resource "aws_security_group" "web_sg" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "web-sg"
+ vpc_id = "vpc-0f1e2d3c4b5a69788"
+ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = ""
+ from_port = 443
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 443
},
]
}
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"

Without -out, a plan is something you read and then throw away. With -out=tfplan it becomes a file: a precise, machine-readable record of what Terraform intends to do, which you can hand to a reviewer, feed to a policy checker, and apply later. It is also the natural seam for splitting permissions. Planning only reads your resources, so give the plan job read-only cloud credentials and keep the write role for the apply step. One caveat on "read-only": plan still takes the state lock by default, so whatever identity runs it needs write access to whatever does the locking (the small DynamoDB table, or the lock object in the bucket, that stops two applies colliding). In automation, add -input=false so a missing variable fails the build instead of hanging forever on a prompt nobody is there to answer.

Reading a Plan: Four Symbols

terminal
$ terraform plan
output
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_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ address = "prod-db.abc123.us-east-1.rds.amazonaws.com" -> (known after apply)
~ id = "prod-db" -> (known after apply)
~ identifier = "prod-db" -> "prod-db-v2" # forces replacement
# (48 unchanged attributes hidden)
}
# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0abc123def4567890"
~ instance_type = "t3.micro" -> "t3.small"
# (31 unchanged attributes hidden)
}
Plan: 1 to add, 1 to change, 1 to destroy.

Four marks carry all the meaning, like chalk on a wall before the builders arrive. + creates something new. ~ changes it where it stands, no rebuild. - destroys it. And -/+ destroys it and builds a replacement, which for anything holding data means the data goes with it. Terraform names the guilty attribute by tagging that line with # forces replacement, and any value it cannot know until the resource exists shows up as (known after apply). Read the per-resource headers first. They are written in plain English on purpose: "will be updated in-place", "must be replaced", "will be destroyed".

The summary line deserves suspicion. "Plan: 1 to add, 1 to change, 1 to destroy" reads like three separate events. It is one. The add and the destroy are a single database being torn down and rebuilt because somebody renamed it. Terraform counts a replacement as one create plus one delete, so the word "replace" never appears in that summary at all. Read the body, not the headline. Piping through terraform plan -no-color | grep -E 'must be replaced|will be destroyed' takes two seconds and is the cheapest safety habit in this whole workflow.

A replacement destroys first, by default
-/+ means Terraform deletes the existing resource and then creates its replacement, in that order. For a database, a persistent disk, or anything else holding state, that is data loss plus an outage, triggered by a one-word edit to a field the provider cannot change in place. Before you approve a replacement of anything stateful, pick deliberately: revert the code, guard the resource with lifecycle { prevent_destroy = true }, or schedule a real migration with a snapshot and a maintenance window. Setting lifecycle { create_before_destroy = true } flips the order so the new resource exists before the old one dies, which helps for stateless instances behind a load balancer and does nothing whatsoever for your data.
main.tf
resource "aws_db_instance" "main" {
identifier = "prod-db"
engine = "postgres"
engine_version = "16.3"
instance_class = "db.t3.medium"
allocated_storage = 100
deletion_protection = true # enforced by AWS itself
lifecycle {
prevent_destroy = true # Terraform refuses to even produce a plan that destroys this
}
}
terminal
$ terraform plan # same rename attempt, now with the guard in place
output
│ 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.

prevent_destroy is a refusal at plan time, not a padlock on the cloud resource. It is a DO NOT DEMOLISH sign taped to the wall: it stops the crew who read your drawings, and it stops nobody who wanders in off the street. Terraform errors out instead of producing a plan at all, which is precisely the behaviour you want guarding a production database. It will not stop anyone deleting that instance in the web console, and it evaporates the moment someone deletes the resource block from your code, because the rule lives inside that block. It also blocks a whole-config terraform destroy until a human takes the guard off, which is a feature. Use it as a tripwire on things that must never be rebuilt casually, and keep the cloud's own protection switched on too: deletion_protection = true on an RDS instance (AWS's managed relational database service) is enforced by AWS, not by your working copy.

Let a Machine Read the Plan Too

You will skim a 400-line plan at six on a Friday. A script will not. terraform show -json turns a saved plan into structured data in JSON (JavaScript Object Notation, a plain-text format machines parse reliably), the same estimate handed over as a spreadsheet something can total up. Every resource carries an actions array: ["create"], ["update"], ["no-op"], ["delete"], or ["delete","create"] for a replacement (["create","delete"] when create_before_destroy is set). That turns "please check for destroys" from a rule people forget into a gate the pipeline enforces.

terminal
$ terraform plan -out=replace.tfplan > /dev/null # the replacement plan, saved this time
$ terraform show -json replace.tfplan > plan.json
$ # what is about to happen, one resource per line (jq reads JSON, @tsv separates with tabs)
$ jq -r '.resource_changes[] | [.address, (.change.actions | join("+"))] | @tsv' plan.json
$ # the gate: list anything that will be deleted, then fail the build if the list is not empty
$ # index() returns 0 on a first-position match, and jq counts 0 as true
$ jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' plan.json
output
aws_db_instance.main delete+create
aws_instance.web update
aws_db_instance.main

That same JSON is what policy tools eat. conftest test plan.json runs rules written in Rego, the language of Open Policy Agent (a general-purpose engine for saying yes or no to a piece of data), and checkov -f plan.json checks the plan against a large library of cloud misconfiguration rules. Scanning the plan beats scanning the .tf files, because the plan holds final resolved values: variables filled in, modules expanded, provider defaults applied. A security group that looks tightly scoped in HCL (HashiCorp Configuration Language, the language Terraform files are written in) can still land on 0.0.0.0/0, meaning every address on the internet, once a variable and a module default have had their say. Only the plan shows you that.

Then clean up after yourself. A plan holds the real attribute values of your resources, so a generated database password, a private key, or an access token ends up sitting inside tfplan and inside the JSON you produced from it. Marking a variable or output sensitive = true only hides it from the console rendering. The value is still in the plan file, still in the JSON, still in state. So do not publish plan files or plan JSON as build artifacts the whole company can download, do not paste raw plan output into a public pull request or an open chat channel, and delete plan files from build agents when the run ends. Give tfplan the same care you give terraform.tfstate.

Apply Exactly What You Reviewed

terminal
$ terraform apply tfplan
output
aws_security_group.web_sg: Creating...
aws_security_group.web_sg: Creation complete after 2s [id=sg-0a1b2c3d4e5f6789a]
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Still creating... [20s elapsed]
aws_instance.web: Creation complete after 32s [id=i-0abc123def4567890]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

No confirmation prompt appeared. Handing apply a saved plan file means the review already happened and the file is the approval, so Terraform carries out those changes and nothing else. Bare terraform apply behaves differently: it computes a fresh plan, shows it to you, and waits for you to type yes. Add -auto-approve and it computes a fresh plan and runs it with nobody watching. In a pipeline where a human approved some plan output pasted into a chat message twenty minutes ago, that fresh plan is a different plan, built against whatever the state looks like now.

terminal
$ # 20 minutes later, after a colleague applied their own change
$ terraform apply tfplan
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.

Terraform guards that gap for you. A saved plan records the exact version of the state it was built from, so if anyone applied anything in between, your file gets rejected rather than quietly folded into the new situation. Re-plan, re-read, re-approve. That refusal is a feature you will be grateful for the first time it saves you.

Prove It Landed

terminal
$ terraform plan
output
aws_security_group.web_sg: Refreshing state... [id=sg-0a1b2c3d4e5f6789a]
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
No changes. Your infrastructure matches the configuration.
Terraform has compared your real infrastructure against your configuration and
found no differences, so no changes are needed.

Two words are the acceptance test: "No changes". Terraform re-read your cloud account, held it up against your code, and found nothing left to do. If a plan run straight after an apply still wants to change something, chase it down. Usually it is a provider bug, a value the cloud rewrote behind your back (a lowercased name, a reformatted policy document, a default port filled in), or another piece of automation fighting Terraform for control of the same resource. A stack that will not settle at "No changes" is a stack that will surprise you during an incident, which is the worst possible moment to be surprised.

Destroy, and the Guardrails Around It

terminal
$ terraform destroy # identical to: terraform apply -destroy
output
aws_security_group.web_sg: Refreshing state... [id=sg-0a1b2c3d4e5f6789a]
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
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-0e2c8caa4b6378d8c" -> null
- id = "i-0abc123def4567890" -> null
- instance_type = "t3.micro" -> null
}
# aws_security_group.web_sg will be destroyed
- resource "aws_security_group" "web_sg" {
- id = "sg-0a1b2c3d4e5f6789a" -> null
- name = "web-sg" -> 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-0abc123def4567890]
aws_instance.web: Destruction complete after 41s
aws_security_group.web_sg: Destroying... [id=sg-0a1b2c3d4e5f6789a]
aws_security_group.web_sg: Destruction complete after 1s
Destroy complete! Resources: 2 destroyed.

destroy is apply aimed at an empty desired state, which is why it prints a plan and asks for confirmation in the same shape. Notice the order. The instance dies before the security group it depends on, because Terraform walks the dependency graph backwards on the way down. Two things about blast radius (how much of your estate goes away if this goes wrong) matter here. First, destroy removes everything in this state file and nothing outside it, so a single state file covering all of production sits one yes away from taking all of production with it. That is the strongest argument there is for splitting state per environment, and usually per service too. Second, you can review a destroy before you run it: terraform plan -destroy -out=tfplan writes the full list to a file, and terraform apply tfplan then runs precisely that list. If you meant one resource, -target=aws_instance.web narrows the scope, but treat -target as break-glass. Routine use leaves your code and your state quietly out of step.

Drift: When the Plan Tells You Someone Else Was Here

Someone changes a lock in your building and does not mention it. You would rather find out from a scheduled walk-round than from a stranger in the corridor. Infrastructure has the same problem: a console click during an incident, a script from a neighbouring team, or an intruder widening a security group to keep their way back in. That gap between your code and reality has a name, drift, and Terraform spots it for free, because every plan refreshes state against the live API before it compares anything. terraform plan -refresh-only isolates that half of the job. It reports what changed outside Terraform and proposes no changes of its own.

terminal
$ terraform plan -refresh-only
output
aws_security_group.web_sg: Refreshing state... [id=sg-0a1b2c3d4e5f6789a]
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-0a1b2c3d4e5f6789a"
~ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ from_port = 22
+ protocol = "tcp"
+ to_port = 22
},
# (1 unchanged element 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 new values in the Terraform state without changing any remote
objects.

Read that as an alarm. Something opened SSH (Secure Shell, the remote login service that listens on port 22) to the entire internet on a security group Terraform owns, and Terraform found it without anyone filing a ticket. You have two honest responses. If the change was legitimate, accept it into state with terraform apply -refresh-only and then update your code to match, so the next plan goes quiet. If it was not legitimate, the fix is already written: your code is the desired state, that rule is not in it, and the next ordinary apply removes it. Two caveats before you relax. Reverting a rule is not the same as handling an incident, so go and find out who made that call and how, in CloudTrail (AWS's log of who called which API) or your provider's equivalent. And Terraform only reverts what it manages. Inline ingress blocks like this one are owned wholesale, so a stray rule gets stripped. If your rules live in separate aws_vpc_security_group_ingress_rule resources, a rule added by hand is an object Terraform has never heard of, and it will sit there unmentioned forever.

So run the check on a schedule and send the result somewhere a human reads. -detailed-exitcode turns the answer into a number: 0 for an empty plan, 1 for an error, 2 for a plan with something in it. For a scheduled alarm, use an ordinary plan rather than the refresh-only variant, because the question worth asking hourly is whether the live world still matches the code. Exit 2 then catches drift and it catches a change that got merged but never applied. Both deserve a look. systemd (the service manager that starts and supervises nearly everything on a modern Linux box) already knows how to run a job on a timer and complain when it fails, so a non-zero exit becomes a failed unit, and a failed unit triggers whatever you name in OnFailure.

/etc/systemd/system/tf-drift.service
[Unit]
Description=Terraform drift check for prod
Wants=network-online.target
After=network-online.target
OnFailure=tf-drift-alert.service
[Service]
Type=oneshot
User=terraform
WorkingDirectory=/srv/infra/prod
Environment=TF_IN_AUTOMATION=1
# read-only cloud credentials come from the host's instance role
# -lock=false keeps this hourly check from fighting a real apply over the state lock
ExecStart=/usr/bin/terraform plan -detailed-exitcode -input=false -no-color -lock=false
/etc/systemd/system/tf-drift.timer
[Unit]
Description=Run the Terraform drift check hourly
[Timer]
OnCalendar=hourly
RandomizedDelaySec=10m # avoid every host hammering the API on the hour
Persistent=true # if the box was off, run once on boot
[Install]
WantedBy=timers.target
terminal
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now tf-drift.timer
$ systemctl status tf-drift.service --no-pager
output
Created symlink /etc/systemd/system/timers.target.wants/tf-drift.timer → /etc/systemd/system/tf-drift.timer.
× tf-drift.service - Terraform drift check for prod
Loaded: loaded (/etc/systemd/system/tf-drift.service; static)
Active: failed (Result: exit-code) since Tue 2026-07-21 14:00:31 UTC; 18min ago
TriggeredBy: ● tf-drift.timer
Process: 4412 ExecStart=/usr/bin/terraform plan -detailed-exitcode -input=false -no-color -lock=false (code=exited, status=2)
Main PID: 4412 (code=exited, status=2)
CPU: 1.204s
Jul 21 14:00:29 ops-01 terraform[4412]: Note: Objects have changed outside of Terraform
Jul 21 14:00:30 ops-01 terraform[4412]: Plan: 0 to add, 1 to change, 0 to destroy.
Jul 21 14:00:31 ops-01 systemd[1]: tf-drift.service: Main process exited, code=exited, status=2/INVALIDARGUMENT
Jul 21 14:00:31 ops-01 systemd[1]: tf-drift.service: Failed with result 'exit-code'.
Jul 21 14:00:31 ops-01 systemd[1]: Failed to start Terraform drift check for prod.

status=2/INVALIDARGUMENT is systemd's stock name for exit code 2 and has nothing to do with arguments. Here it means the plan came back non-empty: one security group needs changing back. Keep the run genuinely read-only, so the terraform user's credentials can describe resources and change nothing. A drift checker holding admin rights is a lovely target, because it runs on a schedule, on a box nobody logs into, with keys to production. And point that alert unit at a channel on-call actually watches. An alarm nobody reads is worse than no alarm at all, since it leaves you feeling covered.

Quick check
01A pipeline runs terraform plan, posts the output to Slack for a human to approve, then a second job runs terraform apply -auto-approve. What is actually wrong with this?
Correct — Bare apply re-plans against current state, so anything that changed between the review and the apply silently rewrites the outcome. Save it with plan -out=tfplan and apply that file.
Incorrect — Too broad. -auto-approve is normal in automation. The danger here is that it approves a re-plan nobody saw, not the flag itself.
Incorrect — Wrong premise. Plan takes the state lock by default and you have to pass -lock=false to opt out. Not the flaw here.
Incorrect — A formatting annoyance that -no-color fixes. The real problem is that apply runs a different plan entirely.
02The lesson recommends running policy checks such as checkov or conftest against the JSON plan (terraform show -json) rather than against the raw .tf files. Why does scanning the plan catch misconfigurations the .tf files can hide?
Incorrect — tools like checkov and tfsec do read HCL (HashiCorp Configuration Language) directly; the real advantage is resolved values, not parseability.
Incorrect — it is not about file size, and a fully resolved plan is often larger than the source.
Correct — a security group that looks tightly scoped in HCL can still resolve to the whole internet once a variable and a module default have had their say, and only the plan shows that.
Incorrect — .tf files can absolutely define rules; the problem is that their final values are not yet resolved in source.
03A plan shows -/+ on aws_db_instance.main with # forces replacement on one attribute, and 'Plan: 1 to add, 1 to change, 1 to destroy'. A colleague suggests adding lifecycle { create_before_destroy = true } so the database has 'no downtime'. What actually happens to the data?
Incorrect — it changes only the order of destroy and create; the resource is still replaced.
Correct — create_before_destroy helps stateless instances behind a load balancer and does nothing whatsoever for your data.
Incorrect — Terraform never migrates data between resources; it only creates and destroys them.
Incorrect — Terraform can and will replace it, which is exactly the danger the -/+ plan is warning about.

Try this

Run terraform init 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 replacement destroys first, by default. 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