backend / iac / terraform / 04_plan_apply_drift_import.md

Terraform — Plan/Apply Lifecycle, Drift, and Import

6 interview angles 6 min read source

Terraform — Plan/Apply Lifecycle, Drift, and Import

The day-to-day operational mechanics: how plan/apply actually work, how to detect and handle drift, and how to bring existing resources under Terraform.

The core lifecycle

terraform init     download providers + modules, configure the backend
terraform plan     compute the diff: desired (.tf) vs current (state, refreshed against reality)
terraform apply    execute the diff
terraform destroy  remove everything in this state

terraform plan

plan does three things:

  1. Refresh — reads the real attributes of tracked resources from the cloud, updating its in-memory view of state.
  2. Diff — compares desired config against that refreshed state.
  3. Output a plan — the set of actions: create / update in-place / destroy / replace (destroy-then-create, shown as -/+).
  # aws_instance.web will be updated in-place
  ~ resource "aws_instance" "web" {
      ~ instance_type = "t3.micro" -> "t3.medium"
    }

  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ engine_version = "14.7" -> "16.1"  # forces replacement
    }

Always read the plan before applying. The line that matters most: -/+ (replace) and - destroy. A replace on a stateful resource (a database, a volume) means data loss. The plan is the safety check.

terraform plan -out=tfplan saves the plan; terraform apply tfplan applies exactly that — no re-diff, no surprises between plan and apply. This is the CI pattern (see 05_secrets_testing_ci.md).

terraform apply

Walks the dependency graph and executes the planned actions, parallelizing where the graph allows. It re-runs a refresh+diff first (unless given a saved plan file) — so an apply without a saved plan can do more or less than the plan you looked at, if reality changed in between. Hence: save the plan in CI.

update in-place vs replace

  • In-place — the provider can change the attribute on the live resource (resizing an instance type, changing a tag).
  • Replace (-/+) — the attribute is immutable on that resource, so Terraform destroys and recreates it. Some attributes “force replacement” — the plan annotates them with # forces replacement.

For stateful resources, a replace is dangerous. Mitigations:

  • lifecycle { create_before_destroy = true } — make the new one before destroying the old (avoids downtime; needs the resource to tolerate two existing briefly).
  • lifecycle { prevent_destroy = true } — a guardrail; apply errors instead of destroying. Good on prod databases.
  • Sometimes the fix is a different change path entirely (e.g. an RDS major-version upgrade via the AWS Blue/Green feature instead of a Terraform replace).

Drift

Drift = the real infrastructure no longer matches state, because something changed it outside Terraform — a console click, another tool, an auto-scaling action, a manual hotfix during an incident.

How Terraform surfaces it: the plan refresh step reads reality, sees it differs from state, and the plan shows a diff even though you didn’t change the .tf.

terraform plan -refresh-only   # show drift without proposing config changes

Handling drift — three choices, decide deliberately:

  1. Revert itapply to push reality back to what the .tf says. Correct when the out-of-band change was a mistake or an unauthorized hotfix that should be codified properly.
  2. Absorb it — update the .tf to match the new reality. Correct when the change was legitimate and should become the new declared state.
  3. Ignore that attributelifecycle { ignore_changes = [tags["LastModified"], desired_count] } — for attributes that are expected to drift (an autoscaler manages desired_count; Terraform shouldn’t fight it every plan).

The anti-pattern is letting drift accumulate silently. A periodic plan in CI (drift detection) that alerts on unexpected diffs keeps state and reality honest.

terraform import — adopting existing resources

You have a resource that already exists (created by hand, by CloudFormation, by another team) and you want Terraform to manage it. import brings it into state without recreating it.

Two ways:

CLI (classic):

# 1. Write the resource block in .tf (matching the real resource's config)
# 2. Import the real resource into that block's address
terraform import aws_instance.web i-0abc123def456

# 3. terraform plan — should show "no changes" if your .tf matches reality.
#    Any diff means your .tf doesn't match; fix the .tf, don't apply.

Import block (Terraform 1.5+) — declarative, plannable:

import {
  to = aws_instance.web
  id = "i-0abc123def456"
}

terraform plan then shows the import as part of the plan (and can generate the resource config with -generate-config-out=), and apply performs it. Better than the CLI form because it’s reviewable in the plan and version-controlled.

The hard part of import isn’t the command — it’s writing the .tf to match the existing resource exactly. If your block doesn’t match, the post-import plan shows changes, and applying them modifies a live resource you meant to just adopt. Iterate: import → plan → fix .tf → plan → until clean.

terraform destroy and targeting

  • terraform destroy — tears down everything in the current state. On prod, gate it hard (or prevent_destroy on critical resources).
  • terraform plan/apply/destroy -target=aws_instance.web — operate on a subset. A break-glass tool, not a habit — targeting bypasses the dependency graph and can leave state inconsistent. Use it to escape a stuck state, then go back to full applies.

Common gotchas

  • Applying without reading the plan — especially missing a -/+ replace on a database. The plan is the safety check; read it.
  • apply without a saved plan in CI — it re-diffs; what you reviewed in plan may not be what apply does. Use plan -outapply tfplan.
  • Drift left to accumulate — state and reality silently diverge; eventually a plan proposes scary surprises. Run drift detection.
  • Import without matching .tf — the post-import plan shows changes; applying them mutates the resource you meant to just adopt.
  • -target as a routine — bypasses the dependency graph, risks inconsistent state. Break-glass only.
  • Forgetting init after adding a provider/moduleplan errors until you re-init.
  • A replace on a stateful resource — data loss. prevent_destroy / create_before_destroy / a different change path.

Interview angle

  • “Walk me through plan then apply.”plan refreshes state against real infrastructure, diffs it against the .tf, and outputs the actions (create / update-in-place / destroy / replace). apply executes that graph. In CI, save the plan (plan -out) and apply that exact file so what you reviewed is what runs.
  • “What’s the difference between an in-place update and a replace?” — in-place: the provider mutates the live resource (resize, retag). Replace (-/+): the attribute is immutable, so Terraform destroys and recreates — dangerous on stateful resources. The plan annotates the triggering attribute with # forces replacement.
  • “What is drift and how do you handle it?” — reality changed outside Terraform (console click, autoscaler, manual hotfix); plan’s refresh step surfaces it as a diff with no .tf change. Handle deliberately: revert (apply), absorb (update .tf), or ignore the attribute (lifecycle { ignore_changes }) if it’s expected to drift. Don’t let it accumulate — run drift detection in CI.
  • “How do you bring an existing resource under Terraform?”terraform import (CLI) or an import {} block (1.5+, reviewable in the plan). The command is easy; the work is writing the .tf to match the real resource so the post-import plan shows no changes — otherwise applying mutates a live resource.
  • “Terraform wants to destroy and recreate my production database. What do you do?” — stop and read why (# forces replacement on which attribute). Don’t apply blindly — that’s data loss. Options: create_before_destroy, prevent_destroy as a guardrail, or change it through a non-Terraform path (RDS Blue/Green) and then reconcile state.
  • “When is -target appropriate?” — break-glass only — to escape a stuck or partially-failed state. It bypasses the dependency graph and can leave state inconsistent; routine use is a smell.