OnCallReady

Lesson 12.24 · Terraform: Language & Workflow · 35 min read

The workflow, read like a reviewer

In plain words

Think of renovating a kitchen. First you gather tools and materials (init). Then you check the drawing makes sense: no door drawn into a wall (validate). Then the builder walks through and writes a list: "knock down this wall, move the sink, keep the fridge" (plan). You read that list carefully, because "knock down" cannot be undone. Only then does the builder work (apply), and if they stop halfway because a part is missing, the half-finished work stays; nobody rebuilds the old kitchen.

terraform plan -out=tfplan writes that list down so the builder does exactly what you approved with terraform apply tfplan. destroy is "remove everything we built". Reading a plan, especially -/+ and # forces replacement, is the core skill.

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:

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:

  1. Refresh: ask the providers for the current state of every object in state. Differences are reported as "Objects have changed outside of Terraform".
  2. Diff: compare the configuration with the refreshed state.
  3. 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:

  1. 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.
  2. Search for must be replaced and # forces replacement. Those lines tell you which attribute change is causing a destroy. A storage account replacement loses every blob in it.
  3. (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.
  4. -/+ vs +/-: destroy-then-create (the default) causes downtime; create-then-destroy happens with lifecycle { create_before_destroy = true } (Ch 13).
  5. "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/...).

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:

Why it helps

Most of your Terraform time on a platform team is reading plans in PRs and CI jobs. The situations: a plan says 1 to add, 1 to change, 1 to destroy and the add and destroy are the same storage account being replaced, losing every blob; a CI job that re-plans at apply time instead of applying the reviewed file; someone using -target routinely because the configuration is too big; "Objects have changed outside of Terraform" meaning a colleague's portal hotfix is about to be undone. You will also build the drift-detection job with -detailed-exitcode, and debug a hanging provider with TF_LOG=DEBUG. The exam asks flags and exit codes by name.

Commands in this lesson

terraform

FAQ

What is the difference between terraform apply and terraform apply tfplan?

Bare terraform apply makes a fresh plan, shows it and asks for yes; what gets applied may differ from what you reviewed earlier if state, code or data sources changed. terraform apply tfplan applies exactly the saved plan with no prompt, and refuses with "Saved plan is stale" if state changed since. CI always uses the saved plan. The plan file contains sensitive values, so treat it as a secret.

What does terraform validate catch and what does it miss?

It checks syntax, references, types and argument names against provider schemas, offline and without credentials (it needs init for the schemas; init -backend=false is fine in CI). It does not catch values the API will reject: an invalid SKU, a CIDR outside the VNet, a name already taken globally, a missing role. Those fail at plan or apply.

What replaced terraform taint and terraform refresh?

terraform apply -replace=ADDR replaced taint (Terraform 0.15.2): it forces one object to be destroyed and recreated, but shows it in the plan instead of silently marking state. terraform apply -refresh-only replaced refresh: it updates state to match reality and shows you what changed before saving, instead of silently rewriting state.

Is -target a normal way to apply a single change?

No. It plans only the target and what it depends on, skipping everything else, and Terraform warns that it is for exceptional situations. Legitimate uses: recovering from a broken partial apply, or bootstrapping when a for_each depends on something that must exist first. If you need it routinely, the configuration is too big and should be split.

How do I turn on debug logging, and is it safe to share?

TF_LOG=DEBUG (or TRACE, INFO, WARN, ERROR) logs to stderr; add TF_LOG_PATH=./tf.log to write to a file (it appends). TF_LOG_PROVIDER and TF_LOG_CORE split provider and core logs. TRACE logs include request bodies, so tokens and secrets can appear in them: scrub before attaching one to an issue.

In an interview Junior

How do you read a Terraform plan before approving it?

  1. Read the summary line last, not first. "1 to add, 1 to change, 1 to destroy" can hide a replacement - that is the add and the destroy together.
  2. Search for must be replaced and # forces replacement. -/+ means destroy, then create; the marked attribute is why. A replaced storage account or database loses its data.
  3. Check every - destroy is intended.
  4. (known after apply) is normal on creates, suspicious on something that should be stable.
  5. "Objects have changed outside of Terraform" above the plan means someone changed things by hand, and this plan may undo it.

Then apply exactly what was reviewed: terraform plan -out=tfplan, review, terraform apply tfplan. A plain apply re-plans and can differ. In automation, terraform show -json tfplan | jq can list every delete and stop the job.

Also asked: What does terraform validate check, and what does it not? · Why should you not use -target routinely? · What does terraform plan -detailed-exitcode return, and what is it used for?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.