Why this matters
What you need to know already: everything so far in this chapter - the workflow in one breath (12.1), init and the lock file (12.3), variables (12.5), dependency graph (12.18), count vs for_each (12.20). Exit codes and case (1.7, 6.5), jq (7.11).
On a real team you rarely write the Terraform change yourself. You review someone's pull request, and the thing you review is the plan: which lines will create, change or destroy real infrastructure. This lesson is about each command in detail, and reading a plan the way a careful reviewer does.
What each command actually does
init prepare the working directory - nothing touches your infrastructure
validate check the configuration is internally consistent - offline
plan refresh, diff, and show the proposed actions - changes nothing
apply plan again (or take a saved plan), then make it so and save state
destroy plan to delete everything in state, then do it
init
terraform init prepares the directory:
- Configures the backend (where state lives, 12.1), moving state over if the backend changed.
- Downloads modules (shared code, Ch 13) into
.terraform/modules/. - Resolves and downloads providers into
.terraform/providers/, and writes or checks.terraform.lock.hcl.
It is safe to run any number of times. Run it after cloning, after adding a provider or module, after changing a module source or version, and after changing the backend. Other commands tell you when you forgot:
╷
│ Error: Module not installed
│
│ on main.tf line 14:
│ 14: module "network" {
│
│ This module is not yet installed. Run "terraform init" to install all modules
│ required by this configuration.
╵
Flags worth knowing:
-upgrade move providers/modules to the newest allowed versions (12.3)
-backend-config=... fill in backend settings from the command line (Ch 13)
-reconfigure backend changed: start fresh, do not move state (Ch 13)
-migrate-state backend changed: copy the state to the new place (Ch 13)
-backend=false skip the backend - for validating in CI without credentials
validate
terraform validate checks syntax, references, types and argument names without contacting any API or reading state. It needs init (for provider schemas) but no credentials:
$ terraform validate
Success! The configuration is valid.
# after a typo in main.tf: azurerm_virtual_network.mian
terraform validate
╷
│ Error: Reference to undeclared resource
│
│ on main.tf line 22, in resource "azurerm_subnet" "app":
│ 22: virtual_network_name = azurerm_virtual_network.mian.name
│
│ A managed resource "azurerm_virtual_network" "mian" has not been declared in
│ the root module.
╵
The error says: on line 22 you referenced a VNet called mian, and no such resource block exists. What validate does not catch: values that are wrong for the API (an invalid SKU name, a CIDR outside the VNet, a name already taken). Those fail at plan or apply.
plan
terraform plan does three things:
- Refresh: ask the providers for the current state of every object in state. Differences are reported as "Objects have changed outside of Terraform".
- Diff: compare the configuration with the refreshed state.
- Show the actions, and optionally save them with
-out.
Plan never changes infrastructure. On a shared backend it does take the state lock (a "someone is working on this" flag, Ch 13), so two people cannot change state at once.
apply
terraform apply makes the changes. Without a plan file, apply runs a fresh plan, shows it, and asks:
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value:
Only the literal yes proceeds. -auto-approve skips the question - fine in a lab or after a human has approved the plan elsewhere, reckless on a laptop against prod.
With a plan file (terraform apply tfplan) it applies exactly that plan, with no question, and refuses if state changed since the plan was made (a stale plan):
│ 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.
A failed apply is not rolled back. Whatever was created before the error exists, and state records it. You fix the cause and apply again; Terraform continues from where reality now is.
destroy
terraform destroy is an alias for terraform apply -destroy. It plans the deletion of everything in state, in reverse dependency order, and asks for yes. terraform plan -destroy shows the same plan without doing it - always run it first in anything shared.
Reading a plan
Every plan starts with this legend of symbols:
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
~ update in-place
- destroy
-/+ destroy and then create replacement
+/- create replacement and then destroy
<= read (data resources)
# azurerm_resource_group.main will be updated in-place
~ resource "azurerm_resource_group" "main" {
id = "/subscriptions/.../resourceGroups/rg-orders-dev"
name = "rg-orders-dev"
~ tags = {
+ "owner" = "platform"
}
# (1 unchanged attribute hidden)
}
# azurerm_storage_account.logs must be replaced
-/+ resource "azurerm_storage_account" "logs" {
~ id = "/subscriptions/.../storageAccounts/stordersdevlogs" -> (known after apply)
~ name = "stordersdevlogs" -> "stordersdevlog2" # forces replacement
}
Plan: 1 to add, 1 to change, 1 to destroy.
Two resources here. The resource group gets a new tag in place (~, + inside the tags map = one tag added; (1 unchanged attribute hidden) = Terraform skipped lines that do not change). The storage account is being renamed, and because a storage account's name cannot change, it is replaced.
How to read it, every time:
- The summary line last, not first. "1 to add, 1 to change, 1 to destroy" looks harmless; the replacement is the add and the destroy.
- Search for
must be replacedand# forces replacement. Those lines tell you which attribute change is causing a destroy. A storage account replacement loses every blob in it. (known after apply)is a value that does not exist yet - an ID, an IP. Normal for creates; suspicious if it appears on something that should be stable.-/+vs+/-: destroy-then-create (the default) causes downtime; create-then-destroy happens withlifecycle { create_before_destroy = true }(Ch 13).- "Objects have changed outside of Terraform" above the plan means someone edited things by hand; the plan may be about to undo their change.
Saving plans: -out
terraform plan -out=tfplan # the diff, SAVED
terraform show tfplan # read it again later, human-readable
terraform show -json tfplan | jq # machine-readable, for policy checks
terraform apply tfplan # apply exactly THAT plan
terraform apply with no arguments re-plans. What you reviewed and what gets applied can differ - someone else's change landed in between, or a data source returned something new. A saved plan closes that gap, which is why every automated setup uses it. The plan file is binary, contains every value including sensitive ones, and is tied to the state version (its serial number) it was made against: treat it as a secret and a one-shot artifact (a file one CI step produces and a later step uses).
The JSON form is what automation reads:
terraform show -json tfplan | jq -r '.resource_changes[] | select(.change.actions | contains(["delete"])) | .address'
lists every resource the plan would delete. Reading it with the jq from 7.11: .resource_changes[] = one object per resource, select(...) = keep those whose actions include "delete", .address = print its address. A CI job can refuse to continue when that list is not empty.
Plan modes and options
Flags for terraform plan (most work on apply too):
-refresh-only only update state to match reality; propose no changes
-refresh=false skip the refresh (faster, riskier: works from stale state)
-replace=ADDR force this one object to be destroyed and recreated
-target=ADDR plan only this object and what it depends on
-destroy plan to destroy everything
-var / -var-file set input variables
-out=FILE save the plan
-detailed-exitcode exit 0 = no changes, 1 = error, 2 = there are changes
-input=false never prompt (CI)
-lock=false do not take the state lock (almost never; Ch 13)
-lock-timeout=5m wait for the lock instead of failing at once (Ch 13)
-parallelism=N how many operations at a time (default 10)
-detailed-exitcode is what drift detection jobs use (drift = reality no longer matches the code, because someone changed it by hand; Ch 13). A job runs a plan every night and acts on the exit code ($?, 1.7):
terraform plan -detailed-exitcode -input=false -out=tfplan
case $? in
0) echo "no changes" ;;
2) echo "changes pending - open a ticket" ;;
*) echo "plan failed"; exit 1 ;;
esac
-replace took over from the old terraform taint command (Terraform 0.15.2): it shows the replacement in the plan instead of silently marking state. -refresh-only took over from terraform refresh, for the same reason.
-target is an emergency tool
-target=ADDR limits plan or apply to one resource:
terraform apply -target=azurerm_subnet.app
It plans only that resource and the things it depends on, and skips everything else - including other changes you meant to make. Both plan and apply warn:
╷
│ Warning: Resource targeting is in effect
│
│ You are creating a plan with the -target option, which means that the
│ result of this plan may not represent all of the changes requested by the
│ current configuration.
│
│ The -target option is not for routine use, and is provided only for
│ exceptional situations such as recovering from errors or mistakes, or when
│ Terraform specifically suggests to use it as part of an error message.
╵
Legitimate uses: recovering from a broken partial apply, or the bootstrap case where a for_each depends on something that must exist first. Using it routinely means the configuration should be split.
fmt
terraform fmt is the code formatter (like Prettier):
terraform fmt # rewrite files in the current directory
terraform fmt -recursive # and every subdirectory
terraform fmt -check # change nothing; exit 3 and list files that need it
terraform fmt -diff # show what would change
fmt applies the canonical style: two-space indentation, aligned = within a block, consistent spacing. It never changes meaning. -check exiting non-zero is what makes it a CI gate (a check that stops the pipeline when it fails).
Debug logging: TF_LOG
When a provider misbehaves or a plan hangs, turn on logging. VAR=value command sets an environment variable for that one command only (6.1):
TF_LOG=DEBUG terraform plan # log to stderr
TF_LOG=TRACE TF_LOG_PATH=./tf.log terraform apply # all of it, to a file
TF_LOG_PROVIDER=DEBUG terraform plan # provider logs only
TF_LOG_CORE=TRACE terraform plan # core logs only
TF_LOG=JSON terraform plan # TRACE level, machine-readable
2026-09-23T07:40:01.000Z [INFO] Terraform version: 1.9.8
2026-09-23T07:40:01.000Z [INFO] Go runtime version: go1.22.8
2026-09-23T07:40:01.000Z [INFO] CLI args: []string{"terraform", "plan"}
2026-09-23T07:40:01.000Z [DEBUG] provider: starting plugin: path=.terraform/providers/...
2026-09-23T07:40:02.000Z [DEBUG] provider.terraform-provider-azurerm_v4.14.0_x5: AzureRM Request: GET https://management.azure.com/subscriptions/...
Each line: time, level in brackets, message. The last one shows the provider calling Azure's API (GET https://management.azure.com/...).
- Levels, most to least verbose:
TRACE,DEBUG,INFO,WARN,ERROR. TF_LOG_PATHonly has an effect whenTF_LOG(orTF_LOG_CORE/TF_LOG_PROVIDER) is set, and it appends.- TRACE logs contain request bodies - tokens and secrets included. Never attach one to a public issue without scrubbing it.
Environment variables that change behaviour
TF_VAR_name set an input variable
TF_LOG, TF_LOG_PATH logging
TF_INPUT=0 same as -input=false everywhere
TF_IN_AUTOMATION=1 trims "next step" hints from output in CI
TF_CLI_ARGS_plan="-lock-timeout=5m" extra flags for one subcommand
TF_DATA_DIR where .terraform lives (rarely changed)
TF_WORKSPACE select a workspace (Ch 14)
TF_PLUGIN_CACHE_DIR share downloaded providers between directories
The pipeline shape
A pipeline is the list of steps a CI server runs on every push or pull request - the same idea as the checks that run on your frontend PRs. For Terraform it looks like this:
1. terraform fmt -check -recursive
2. terraform init -input=false
3. terraform validate
4. tflint a linter: style and provider-specific errors
5. checkov / trivy security scanners (public storage, no TLS...)
6. terraform plan -out=tfplan -input=false -> saved as an artifact
7. MANUAL APPROVAL GATE a human reads the plan
8. terraform apply -input=false tfplan
Two non-negotiables. Let the pipeline log in to Azure with OIDC (12.3: a short-lived token, nothing stored), not a long-lived password kept in the CI settings. And never apply to production from a laptop - the pipeline is the only path, so every change is reviewed, logged and reproducible. Ch 14 builds this pipeline (tflint and checkov included).
What you can now do:
- Say exactly what init, validate, plan, apply and destroy touch.
- Read a plan: symbols,
# forces replacement, the summary line, and drift warnings. - Save a plan with
-out, inspect it withshow -json | jq, and use the plan flags.