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:
- Refresh — reads the real attributes of tracked resources from the cloud, updating its in-memory view of state.
- Diff — compares desired config against that refreshed state.
- 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;applyerrors 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:
- Revert it —
applyto push reality back to what the.tfsays. Correct when the out-of-band change was a mistake or an unauthorized hotfix that should be codified properly. - Absorb it — update the
.tfto match the new reality. Correct when the change was legitimate and should become the new declared state. - Ignore that attribute —
lifecycle { ignore_changes = [tags["LastModified"], desired_count] }— for attributes that are expected to drift (an autoscaler managesdesired_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 (orprevent_destroyon 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. applywithout a saved plan in CI — it re-diffs; what you reviewed inplanmay not be whatapplydoes. Useplan -out→apply tfplan.- Drift left to accumulate — state and reality silently diverge; eventually a
planproposes 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. -targetas a routine — bypasses the dependency graph, risks inconsistent state. Break-glass only.- Forgetting
initafter adding a provider/module —planerrors 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
planthenapply.” —planrefreshes state against real infrastructure, diffs it against the.tf, and outputs the actions (create / update-in-place / destroy / replace).applyexecutes 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.tfchange. 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 animport {}block (1.5+, reviewable in the plan). The command is easy; the work is writing the.tfto match the real resource so the post-importplanshows 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 replacementon which attribute). Don’t apply blindly — that’s data loss. Options:create_before_destroy,prevent_destroyas a guardrail, or change it through a non-Terraform path (RDS Blue/Green) and then reconcile state. - “When is
-targetappropriate?” — 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.