Chapter 3: The Core Workflow
Topic 15

The Plan/Apply Lifecycle

WorkflowTooling

Every change you make on GCP through Terraform runs the same three-phase loop: refresh reads the live state of your resources from the Google Cloud APIs, plan computes the diff between that reality and your HCL, and apply executes the API calls that close the gap. The loop is identical whether you are adding a label to the hatch-events-raw bucket or replacing the event-processor Cloud Run service.

Mastering this loop — and learning to read a plan the way you read a code review — is the difference between confidently shipping a change and praying after you type yes. The plan is a contract: it tells you exactly which API calls Terraform is about to make, in symbols you can scan in seconds once you know them. Reading it carefully is the single most valuable habit in the whole workflow.

Refresh, Plan, Apply

When you run terraform plan, the first thing it does is refresh: for every resource in state it queries that resource's API and asks what is true right now. Does hatch-events-raw still exist? What are its current labels? That refreshed picture of reality is then compared to your configuration, and the difference becomes the list of actions Terraform prints. The plan is read-only — it changes nothing on GCP.

terraform apply re-runs that whole computation — refresh, then diff — and then executes the resulting API calls, prompting you to confirm first. The double computation matters: a bare apply does not blindly trust an earlier plan, it builds a fresh one against current reality. That is safe at a laptop and, as you will see, exactly the property you must pin down in CI.

The three-phase loop every change runs
refresh
plan
apply

Reading a Plan

A plan is a list of resources, each prefixed with an action symbol, and under each one a per-attribute diff. Five symbols carry the whole vocabulary. + means create, - means destroy, ~ means update in place, -/+ means replace — destroy the old object and create a new one — and <= marks a data source being read. The -/+ is the one to fear on anything stateful, because replace means the data goes with the old object.

Inside a resource's diff, an attribute shown as (known after apply) is a value GCP assigns at create time and cannot be known in advance — a Cloud Run service's uri, a project number, a generated key. It is not Terraform being uncertain; it is honestly admitting the cloud has not handed back that value yet. The plan below adds one label to the bucket and reads the topic's auto-assigned id.

A plan with an in-place update and a known-after-apply value
  # google_storage_bucket.raw will be updated in-place
  ~ resource "google_storage_bucket" "raw" {
        name   = "hatch-events-raw"
      ~ labels = {
          + "pipeline" = "hatch"
        }
    }

  # google_cloud_run_v2_service.processor will be created
  + resource "google_cloud_run_v2_service" "processor" {
        name = "event-processor"
        uri  = (known after apply)
    }

Read top to bottom, the symbol in the left gutter tells you the verb and the indented lines tell you the exact attributes. A disciplined review is mechanical: scan the gutter for any - or -/+, confirm each one is intended, then approve.

Saved Plan Files

terraform plan -out=tfplan writes the exact computed plan to a file on disk. Hand that file to terraform apply tfplan and Terraform executes precisely those actions with no re-computation and no confirmation prompt — it already has an approved plan, so it just runs it. This two-step is the backbone of safe automation.

The reason it matters is timing. Between the moment a plan is reviewed and the moment it is applied, reality can move — someone edits a resource in the Console, another pipeline runs. A bare apply would re-plan against that moved reality and could execute actions nobody approved. A saved plan file guarantees the change that merges is provably the change that was reviewed.

Refresh-Only Runs

Sometimes someone changes a resource outside Terraform — adds a label to hatch-events-raw in the Console, resizes a disk by hand. A normal plan sees that drift as a difference from config and proposes to revert it. terraform plan -refresh-only and terraform apply -refresh-only do the opposite: they reconcile state to match reality without proposing any config changes, absorbing the out-of-band edit into state instead of fighting it.

Use a refresh-only run when the manual change was intentional and you want state to catch up, then decide separately whether to bring that change into your HCL. It is the correct, non-destructive way to handle drift; reverting someone's deliberate Console fix by running a normal apply is how you cause a second incident.

Targeted Operations and Why They Smell

terraform apply -target=google_pubsub_topic.events_ingest restricts the run to one resource and the things it depends on, ignoring the rest of the configuration. It exists as a break-glass tool: when an apply fails partway and leaves state inconsistent, targeting the broken resource lets you recover without re-running everything. That is a legitimate, occasional use.

Routine -target use is a smell. If you reach for it to "just fix this one resource" as a habit, you are applying a subset of your config, leaving state internally inconsistent, and the next full plan surfaces a pile of deferred changes at once. Frequent targeting almost always means your config is too coupled or you are papering over a dependency problem the graph would have caught.

Saved plan vs bare apply

Bare apply — computes a fresh plan against current reality and prompts you to confirm. Fine at a laptop, where you read the plan it prints and approve it in the same breath. The plan and the apply are the same human's same session.

plan -out=tfplan then apply tfplan — executes the exact actions already reviewed, with no re-computation and no prompt. Use this flow in CI so the change that merges is provably the change that was approved, even if reality drifted between review and merge.

Common Mistakes
  • Running a bare terraform apply in CI off the latest config instead of applying a reviewed -out plan file — the apply re-plans against current reality and can execute actions nobody approved if state drifted between review and merge.
  • Reaching for -target to "just fix this one resource" as a habit — you apply a subset, leave state internally inconsistent, and the next full plan surfaces a pile of deferred changes at once.
  • Seeing (known after apply) on a dependent attribute and assuming Terraform is confused — it simply means GCP assigns that value at create time and the plan cannot show it yet.
  • Editing hatch-events-raw labels in the Console, then running a normal apply and reverting the change — a -refresh-only run was the right move to absorb the drift into state instead of overwriting it.
  • Treating the plan as a formality and piping apply -auto-approve everywhere — the plan is the review gate, and auto-approving a plan you never read is how a -/+ replace of a stateful resource gets discovered after the data is gone.
  • Skimming a plan for the resource count instead of scanning the gutter symbols — a run that "creates 4, changes 1" can hide a -/+ replace of your dataset inside that "1".
Best Practices
  • Always run plan and read it before apply; in CI, run plan -out=tfplan and apply tfplan so the approved plan is the applied plan.
  • Use plan -refresh-only to absorb out-of-band Console changes into state rather than letting a normal plan revert them.
  • Reserve -target for recovering from a failed apply or a known dependency bug, and follow it immediately with a full untargeted plan to confirm convergence.
  • Scan every plan specifically for -/+ replacements on stateful resources — buckets, datasets, disks — before approving, since replace means destroy-then-create.
  • Gate every CI apply behind a reviewed plan file so the merge and the apply are provably the same set of actions.
Comparable tools Pulumi preview and up are the direct analog of plan and apply Config Connector reconciles continuously with no explicit plan step gcloud executes immediately, with no diff or dry-run for most commands

Knowledge Check

What does the refresh phase do at the start of a terraform plan?

  • It queries each managed resource's API to learn its current real state before computing the diff
  • It downloads the latest matching provider version from the Terraform Registry
  • It applies any pending changes that were saved in a previous plan file to the cloud
  • It rewrites your local HCL configuration files to match the resources that currently exist in the cloud

Why is applying a saved plan file safer than a bare apply in CI?

  • It executes the exact reviewed actions with no re-plan, so drift between review and merge cannot slip in unapproved changes
  • It runs noticeably faster in the CI pipeline because it skips the refresh phase and the provider setup step entirely on every run
  • It automatically rolls every change back if any single API call fails mid-apply
  • It encrypts the plan file so any secrets stored in state are never exposed

Someone added a label to hatch-events-raw in the Console on purpose. What is the right way to make state agree without reverting it?

  • Run terraform apply -refresh-only to absorb the change into state
  • Run a normal terraform apply and let it reconcile the resource
  • Run terraform apply -target on the bucket to fix only that resource
  • Delete the bucket from state with state rm and then re-import it

A plan shows the uri of a new Cloud Run service as (known after apply). What does that mean?

  • GCP assigns that value at create time, so the plan cannot display it until apply runs
  • Terraform failed to read the attribute back from the API and is flagging it as an error
  • The attribute is being destroyed and recreated in place by this plan
  • The value is sensitive and has been deliberately redacted from the output

You got correct