plan, apply & destroy
The Terraform workflow, safely.
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.
$ cd /srv/infra/prod$ terraform init
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 providerselections it made above. Include this file in your version control repositoryso that Terraform can guarantee to make the same selections by default whenyou run "terraform init" in the future.Terraform has been successfully initialized!You may now begin working with Terraform. Try running "terraform plan" to seeany changes that are required for your infrastructure.
provider "registry.terraform.io/hashicorp/aws" {version = "5.62.0" # the exact version chosenconstraints = "~> 5.0" # what your code asked forhashes = ["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.
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.)
$ 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
Success! The configuration is valid.Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:+ createTerraform 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: tfplanTo 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
$ terraform plan
Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:~ update in-place-/+ destroy and then create replacementTerraform 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.
resource "aws_db_instance" "main" {identifier = "prod-db"engine = "postgres"engine_version = "16.3"instance_class = "db.t3.medium"allocated_storage = 100deletion_protection = true # enforced by AWS itselflifecycle {prevent_destroy = true # Terraform refuses to even produce a plan that destroys this}}
$ terraform plan # same rename attempt, now with the guard in place
╷│ 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.
$ 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
aws_db_instance.main delete+createaws_instance.web updateaws_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
$ terraform apply tfplan
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.
$ # 20 minutes later, after a colleague applied their own change$ terraform apply tfplan
╷│ 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
$ terraform plan
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 andfound 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
$ terraform destroy # identical to: terraform apply -destroy
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 executionplan. Resource actions are indicated with the following symbols:- destroyTerraform 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: yesaws_instance.web: Destroying... [id=i-0abc123def4567890]aws_instance.web: Destruction complete after 41saws_security_group.web_sg: Destroying... [id=sg-0a1b2c3d4e5f6789a]aws_security_group.web_sg: Destruction complete after 1sDestroy 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.
$ terraform plan -refresh-only
aws_security_group.web_sg: Refreshing state... [id=sg-0a1b2c3d4e5f6789a]aws_instance.web: Refreshing state... [id=i-0abc123def4567890]Note: Objects have changed outside of TerraformTerraform detected the following changes made outside of Terraform since thelast "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 undothese. If you were expecting these changes then you can apply this plan torecord the new values in the Terraform state without changing any remoteobjects.
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.
[Unit]Description=Terraform drift check for prodWants=network-online.targetAfter=network-online.targetOnFailure=tf-drift-alert.service[Service]Type=oneshotUser=terraformWorkingDirectory=/srv/infra/prodEnvironment=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 lockExecStart=/usr/bin/terraform plan -detailed-exitcode -input=false -no-color -lock=false
[Unit]Description=Run the Terraform drift check hourly[Timer]OnCalendar=hourlyRandomizedDelaySec=10m # avoid every host hammering the API on the hourPersistent=true # if the box was off, run once on boot[Install]WantedBy=timers.target
$ sudo systemctl daemon-reload$ sudo systemctl enable --now tf-drift.timer$ systemctl status tf-drift.service --no-pager
Created symlink /etc/systemd/system/timers.target.wants/tf-drift.timer → /etc/systemd/system/tf-drift.timer.× tf-drift.service - Terraform drift check for prodLoaded: loaded (/etc/systemd/system/tf-drift.service; static)Active: failed (Result: exit-code) since Tue 2026-07-21 14:00:31 UTC; 18min agoTriggeredBy: ● tf-drift.timerProcess: 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.204sJul 21 14:00:29 ops-01 terraform[4412]: Note: Objects have changed outside of TerraformJul 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/INVALIDARGUMENTJul 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.
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.