Chapter 2: The Core Workflow
Topic 08

The Plan/Apply Lifecycle

Workflow

Every change Terraform makes goes through the same three phases: refresh reads the current real-world state, plan diffs your configuration against that state, and apply executes the diff. The single fact that makes this safe is that plan is read-only and apply is the only phase that touches anything. That separation is what lets you review an infrastructure change the way you review a pull request — look at the diff first, then approve it.

Most production incidents that trace back to Terraform are not bugs in the tool. They are someone applying a plan they did not read, where a buried -/+ line quietly destroyed and recreated a database. Reading the plan is the skill; the rest is mechanics.

The three phases of every change
Refresh — read real state
Plan — diff & print
Apply — execute & write state

Refresh

Before computing anything, Terraform queries the provider for the current state of every resource it tracks, so the plan reflects reality rather than a stale snapshot in the state file. If someone resized an instance in the console, refresh is where Terraform notices. You can run this phase alone with terraform plan -refresh-only, which shows what drifted in the real world without proposing any configuration-driven changes — the clean way to reconcile state with reality when you suspect drift.

Plan

Plan compares the refreshed state to your configuration and computes one of four actions for every resource: create, update in place, destroy, or destroy-and-recreate. It prints each with a symbol — + create, ~ update in place, - destroy, and -/+ replace. Nothing changes during a plan; it is a proposal. The example below changes a tag (an in-place update) but also changes the AMI, which AWS cannot alter on a running instance, forcing a replacement.

terraform plan — an in-place update and a forced replacement
# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
    ~ ami           = "ami-0aaaaa" -> "ami-0bbbbb" # forces replacement
    ~ instance_type = "t3.micro" -> "t3.small"
    ~ tags          = {
        ~ "Name" = "web" -> "web-1"
      }
  }

Plan: 1 to add, 0 to change, 1 to destroy.

The # forces replacement annotation on the ami line is the most important text in that output. It tells you exactly which argument triggered the destroy-and-recreate. The summary line — 1 to add, 0 to change, 1 to destroy — is the count, but the annotation is the explanation, and on a stateful resource it is the difference between a no-downtime tweak and an outage.

Apply

Apply walks the dependency graph and executes the planned actions in order, calling the AWS APIs and updating state after each resource succeeds. Independent resources run in parallel; dependent ones wait. If apply is interrupted, the state file still records everything that completed, so the next run picks up from a consistent point rather than redoing finished work. Run without a saved plan, apply re-plans first and asks for confirmation, showing you the same diff one more time before it commits.

Saved Plans

In any pipeline, you freeze the plan and apply that exact file. terraform plan -out=tfplan writes the computed diff to disk; terraform apply tfplan executes it without re-planning. This is what makes a reviewer's approval meaningful: apply does exactly what was reviewed, even if config or reality changed in the meantime.

Freeze a plan, then apply exactly what was reviewed
# in CI: produce a reviewable artifact
terraform plan -out=tfplan

# after approval: apply the frozen diff, no re-plan
terraform apply tfplan

The flip side is the part that surprises people: a saved plan applies the diff it was built from, not a fresh diff against current reality. If the world changed between plan and apply, apply still executes the old plan. That is the intended behavior — you reviewed that diff — but it means you keep the window between plan and apply short so the frozen diff has not gone stale.

Reading Plan Output

The one distinction worth internalizing is ~ versus -/+. A ~ changes a mutable attribute in place with no disruption — an instance tag, an autoscaling group's desired count. A -/+ destroys the resource and builds a new one because the changed argument is immutable on the cloud side, and for a database or an EBS volume that means data loss or downtime unless you have planned for it. Scan every plan for -/+ on stateful resources before you approve, and read the # forces replacement annotation to know which argument is responsible.

In-place update vs Replacement

In-place update (~) — changes a mutable attribute with no destroy step and no downtime, like a tag or a description. Safe to apply without ceremony; the resource keeps its ID and its data.

Replacement (-/+) — destroys and recreates because the changed argument is immutable on the cloud side, such as an EC2 AMI or a subnet's CIDR. The new resource gets a new ID, and any data or in-flight traffic on the old one is gone. Always scan for this before applying a stateful resource.

Common Mistakes
  • Reading only the final summary line and missing a -/+ replacement of a stateful resource buried higher in the plan — the count says "1 to destroy" and you scrolled past the one that mattered.
  • Assuming apply on a saved plan re-plans against current reality — it executes the frozen plan even if the world has since changed, which is the point but catches people off guard.
  • Ignoring the # forces replacement annotation, the one piece of text that names exactly which argument triggers the destroy-and-recreate.
  • Letting a long gap pass between plan and apply in CI, so the applied diff no longer matches the reality a reviewer approved against.
  • Treating -refresh-only as a no-op — it can write detected drift back into state, which is what you want, but only if you meant to reconcile.
Best Practices
  • In automation, always plan -out=tfplan and apply tfplan so apply executes exactly the diff that was reviewed.
  • Scan every plan for -/+ replacements on stateful resources — databases, volumes, anything holding data — before approving.
  • Use terraform plan -refresh-only to reconcile state with reality when you suspect drift, without making configuration-driven changes.
  • Keep the window between plan and apply short in pipelines so a saved plan never applies against meaningfully changed reality.
  • Read the # forces replacement annotation, not just the summary count, to know why a resource is being recreated.
Comparable tools CloudFormation change sets preview actions like a plan Pulumi preview and up mirror plan and apply Ansible --check is a dry run but, being stateless, cannot show replacement

Knowledge Check

A plan shows -/+ next to your RDS instance. What is actually about to happen?

  • Terraform will destroy the database and create a new one, because an immutable argument changed
  • Terraform will update the existing database in place with zero downtime and no data loss
  • Terraform will refresh the database's recorded state but make no real change to the instance
  • Terraform will spin up a second database and leave the first one entirely untouched and running

You run plan -out=tfplan, wait an hour while someone changes a resource in the console, then run apply tfplan. What does apply do?

  • Executes the frozen diff from the saved plan, not a fresh diff against the now-changed reality
  • Re-plans against the current real-world reality and then applies the freshly recomputed diff
  • Refuses to run and errors out because the saved plan is now older than the latest recorded state
  • Discards the saved plan and prompts you to plan again

Why pair plan -out with apply <file> in a pipeline instead of just running apply?

  • So the applied changes are exactly the ones a reviewer approved, with no chance of a recomputed diff
  • It is the single only available way to run an apply without ever showing an interactive confirmation prompt
  • It encrypts the entire state file at rest during the apply step for added security
  • It runs the apply in parallel across far more resources than a normal apply would

What does the refresh phase do?

  • Reads the current real-world state of tracked resources so the plan reflects reality, not a stale snapshot
  • Downloads and installs the latest available provider plugin versions from the registry before the plan begins
  • Applies any pending changes that were queued up in state since the previous run
  • Deletes the live resources that are no longer present in the configuration files

You got correct