Skip to main content
RunBook Academy

← All runbooks in Terraform

low riskinformational~30 min

Runbook: Triage a Drift Finding

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · Nothing is about to apply this workspace. If a scheduled apply is due before triage can finish, stop — this is the incident case and belongs in the drift-incident runbook, which freezes the pipeline first.
  • · terraform workspace show and terraform version are run and recorded. A finding attributed to the wrong workspace is worse than no finding, and a plan produced by a different CLI version is not comparable with the detector run.
  • · The working directory is on the same commit CI plans from — git rev-parse HEAD and git status --porcelain are both recorded. An uncommitted local edit makes every plan in this runbook a statement about your laptop.
  • · The credentials in the environment are read-only, confirmed rather than assumed. On AWS, aws sts get-caller-identity names the principal; check it against what that principal is allowed to do.
  • · The detector run id, its timestamp, and its exit code are recorded. Exit 1 is not "no drift" — it means detection itself failed, and the estate has had no drift signal since.
  • · The last clean detector run is known. That timestamp is the left edge of every audit-log query in this runbook; without it the queries have no window.
  • · It is agreed who may run plans against this workspace while triage is in progress, and said in the team channel. A plan holds the state lock by default, and a held lock is routinely mistaken for a stuck one.
  • · No command in this runbook writes. If a step seems to need terraform apply, terraform refresh, terraform import or terraform state, triage has ended and the reconcile runbook has started.

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Fix the coordinates before taking any reading: workspace, backend, Git commit, CLI version, provider versions and the identity the credentials resolve to. Record them alongside the findings — they are what makes the findings comparable with the next run.
  2. 2Take the reading with all three instruments. terraform plan -refresh=false compares configuration to state and makes no API calls; terraform plan -refresh-only compares state to the refreshed world; a full terraform plan compares configuration to the refreshed state. Record all three exit codes.
  3. 3Read the pair of exit codes to decide which record moved. Only -refresh=false clean with -refresh-only dirty is drift. The reverse pair is an unapplied configuration change and belongs to whoever owns the merge queue, not to drift triage.
  4. 4Enumerate the findings from the saved plan file, not from the terminal. terraform show -json exposes resource_drift even when the human output omits it, and since Terraform 1.2 the human output of a full plan omits external changes it judges irrelevant to the proposed actions.
  5. 5For each address record four facts: the address, the attribute that differs, the direction (which side holds which value), and what Terraform would do about it — ~ for an in-place update, -/+ for a replacement. The fourth fact is what makes a finding urgent, not the size of the diff.
  6. 6Pair each Terraform address with the real-world identifier via terraform state show, then query the provider audit log for that identifier over the window since the last clean detector run.
  7. 7Join the audit-log event to something outside the cloud: a change record, an incident, an on-call roster, a deploy log. The audit log says what happened; only the join says whether it was meant to.
  8. 8Assign one of the four course shapes to each finding — undesired, unrecorded intent, uninteresting, unknown — and write down the evidence that forced it, together with the fact that would have made a different shape correct.
  9. 9Write the finding record: coordinates, address, attribute, direction, proposed action, audit-log event, classification, evidence, resource owner, and the recommended path. This record is the deliverable of this runbook.
  10. 10Hand the record to the named resource owner and stop. Reconciliation is a separate procedure with a separate risk rating; triage that executes its own recommendation has no reviewer.

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓Every finding in the record names an address, an attribute and a direction. "aws_security_group.alb_sg drifted" is not a finding; it is a rumour.
  • ✓Every finding carries a proposed action of ~ or -/+, taken from the plan rather than assumed. Any -/+ on a production resource is flagged in the record itself, not left for the reconciler to discover.
  • ✓Every finding carries either an audit-log event with an identity and a timestamp, or an explicit statement that no event was found and which log was searched over which window.
  • ✓Every finding carries exactly one of the four shapes, and the evidence line under it would convince a second reader who had not run the plans.
  • ✓The three exit codes are recorded, and the pair supports the claim that this is drift rather than an unapplied configuration change.
  • ✓The workspace, commit, CLI version and provider versions are recorded, so the next triage can tell "this drifted again" from "this is a different estate".
  • ✓The state was not written. terraform state pull | jq -r .serial matches the serial recorded in the pre-checks, and no plan in this runbook was applied.
  • ✓A named person owns each finding. "The platform team" is not a name and cannot be paged.

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Triage has no rollback in the ordinary sense, because nothing in it changes the world. What it can leave behind is a written state change and a wrong classification, and those are what the following entries undo.
  • ↶If terraform apply -refresh-only or terraform refresh was run by reflex, the state now records the drifted values and a refresh-only detector will go quiet while the world is still wrong. Say so immediately, note the new state serial, and hand the finding to the reconcile runbook — the recovery is a reconciliation, not an undo.
  • ↶If a plan was applied, this stopped being triage. Stop, treat it as an unintended apply, and go to the runbook for that. Do not attempt to reverse it from inside this procedure.
  • ↶If a classification is later contradicted by evidence, correct the record rather than overwriting it. The wrong classification and the reason it was wrong are what stop the next reader repeating it.
  • ↶If the finding record was handed on with a -/+ unflagged, retract it and re-issue. A reconciler working from an incomplete record will discover the replacement by executing it.
  • ↶If triage held the state lock long enough to block a colleague, release it by letting the plan finish rather than force-unlocking, and say in the channel that the lock was yours.

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the security team when the audit log shows no event, an identity nobody recognises, or a change made outside a change window with nothing behind it. Do not classify it as something else first. Both of the comfortable classifications damage the investigation: one adopts the change as declared intent, the other erases the evidence by putting the world back.
  • · Escalate to the resource owner and to change approval before any finding marked -/+ on a production resource leaves triage. That finding is a replacement proposal wearing the word "drift", and it needs the approvals a replacement needs.
  • · Escalate to whoever owns the detector when it exits 1 rather than 2. A detector that has been erroring has produced no drift signal for as long as it has been erroring, and the queue you are triaging is not the whole queue.
  • · Escalate to the platform or provider owner when the same address appears in triage repeatedly. Something else writes that attribute; until it is named, every reconciliation is a loop and every triage is a repeat.
  • · Escalate — and stop — when a finding cannot be classified after the audit-log query. Hold it, hand it on with the evidence, and let the decision be made by someone with the context. An unforced guess is the one outcome triage is meant to prevent.

Most drift is not an incident. It is a queue. The detector runs on the hour, exits 2 a few times a week, and leaves a list of addresses that somebody has to look at properly — with the applies still running, nobody paged, and enough time to be right rather than fast.

That is this runbook, and its output is not a fixed estate. Its output is a classified finding: an address, an attribute, a direction, a provenance, one of four shapes, and the evidence that forced the shape. Turning that into a change is a different procedure with a different risk rating.

The failure this order exists to prevent is triage that reaches for a command. Both available directions — put the world back, or accept the world into the state — look reasonable in the moment, and the plan output cannot tell you which one is right. Only the provenance can, and the provenance takes twenty minutes to establish.

Is this the right runbook?

SituationWhere to go
The detector exited 2, nothing is scheduled, nobody is pagedHere.
An apply is due before triage could finishNot here. Freeze the pipeline first — that is the drift-incident runbook
The change has no attributable author, or an unrecognised oneHere, as far as step 6, then straight to security
The classification is already made and the owner has agreed a pathNot here. That is the reconcile runbook
A plan wants to create things that visibly already existNot drift at all. The state has lost track of them, and the state runbooks handle that
The plan cannot run at all — backend unreachable, credentials rejectedNot drift. Nothing here applies until a plan runs
The same address has been triaged three times this monthHere, but the finding is “something else owns this attribute”, and it escalates

Blast radius

Nothing in this runbook changes the world, and that is a property worth defending rather than assuming. Two commands break it, and both are one keystroke from commands that do not:

  • terraform apply -refresh-only writes the refreshed values into the state. It changes nothing at the provider, so it feels read-only, and it is not.
  • terraform refresh is worse, because it shows you nothing first. The documentation calls it deprecated and describes it as “effectively an alias for terraform apply -refresh-only -auto-approve”. On Terraform 1.9.8 it prints one Refreshing state... line, writes the state, and exits 0 with no diff and no prompt.

Everything else here is a plan or a read. A plan does not write the state — but it does take the state lock by default, so a long triage session blocks a colleague’s apply. Say so in the channel, or pass -lock=false on plans you are only reading, accepting that the world may move underneath a plan that holds no lock.

Step 1: fix the coordinates

Read-only / Safewhat estate is this, exactly
terraform workspace show
terraform version

# The configuration side of the comparison, pinned.
git rev-parse HEAD
git status --porcelain    # must be empty, or every plan below is about your laptop

# The identity the plans will run as.
aws sts get-caller-identity

# The state as it stands. Record the serial; nothing in this runbook may change it.
terraform state pull | jq -r '.serial, .lineage'

These six commands are not ceremony. A drift finding is a claim that two records disagree, and a claim like that is meaningless without saying which copies of the records. Half of the findings that reappear a month later were never the same finding twice: the first was a plan run against staging, or from a branch, or by a CLI a minor version behind the one CI uses.

Step 2: take the reading with all three instruments

Terraform has three records — the configuration in Git, the state on the backend, the world at the provider API — and three plan modes that compare different pairs of them. Triage runs all three, because the pair of exit codes is what tells you which record actually moved.

CommandComparesTouches the provider API
terraform plan -refresh=falseconfiguration against the state as recordedNo
terraform plan -refresh-onlythe state as recorded against the refreshed worldYes
terraform planconfiguration against the refreshed stateYes
Read-only / Safethree readings, three exit codes
terraform plan -refresh=false -input=false -no-color -detailed-exitcode > code.txt
echo "refresh=false  exit $?"

terraform plan -refresh-only -input=false -no-color -detailed-exitcode \
-out=drift.tfplan
echo "refresh-only   exit $?"

terraform plan -input=false -no-color -detailed-exitcode -out=full.tfplan
echo "full           exit $?"

-detailed-exitcode reports 0 for an empty diff, 1 for an error, and 2 for a non-empty diff. Read the first two together:

-refresh=false-refresh-onlyWhat movedWhose problem
02The world. This is drift.Yours — continue to step 3
20The code. A merged change has not been applied.The merge queue, not drift triage
22Both, independently.Separate them before classifying anything
00Nothing.Whatever produced the alert — start there

The second row is the one worth internalising. An unapplied configuration change looks exactly like drift in a full plan: same ~, same attributes, same alarming summary. It is not drift, nobody touched production, and reverting the world would revert a change that was reviewed and merged on purpose.

Step 3: enumerate from the plan file, not from the terminal

The terminal is a summary. The plan file is the record, and on a full plan they no longer say the same thing.

Since Terraform 1.2 the human-readable output of an ordinary plan shows only the external changes that may have contributed to the actions it is proposing. HashiCorp stated the rule when the behaviour shipped: only external changes which may have contributed to changes in the plan will be shown, and if there are no changes in the plan, no external changes will be shown in the CLI at all — while all of the refresh information is still stored within the plan, and a refresh-only plan will show it.

On Terraform 1.9.8, a full plan whose proposed action already accounted for the drift printed no Objects have changed outside of Terraform block at all, and terraform show -json on that same plan file listed the drifted address under resource_drift. The CLI hid a finding the plan file was carrying.

Read-only / Safethe enumeration that does not lie to you
# Every drifted address, with the action refresh recorded against it.
terraform show -json drift.tfplan \
| jq -r '(.resource_drift // [])[]
         | [.address, (.change.actions | join("+"))] | @tsv'

# The same field on the FULL plan, which the terminal may not have printed.
terraform show -json full.tfplan \
| jq -r '(.resource_drift // [])[] | .address'

# What Terraform proposes to do about it, and which of those are replacements.
terraform show -json full.tfplan \
| jq -r '.resource_changes[]
         | select(.change.actions != ["no-op"])
         | [.address, (.change.actions | join(","))] | @tsv'

delete,create or create,delete in that last column is a replacement: Terraform has no way to reach the declared value in place, so it intends to destroy the live object and build another. update is an in-place edit. The two are separated by one word in the JSON and by two characters in the terminal, and by an outage in production.

Step 4: read each finding as four facts

For every address, write down four things, in this order, before forming any opinion about it:

  1. The address. aws_security_group.alb_sg, including the module path if it has one.
  2. The attribute. Not “the security group changed” — ingress, or description, or tags["Owner"].
  3. The direction. Which side holds which value. Both renderings print before -> after, but before means different things: in a drift entry the left side is the state as Terraform last recorded it and the right side is what the provider returned, while in a proposed change the left side is the refreshed state and the right side is what the configuration asks for. Take the direction from which block the line came out of, never from the arrow alone.
  4. The proposed action. ~ or -/+, from step 3’s JSON rather than from memory.

Fact four is the one that decides urgency. A large in-place diff is usually cheap to resolve; a one-attribute -/+ on a database is a replacement proposal, and it needs the approvals a replacement needs before anybody runs anything.

Step 5: establish provenance

Read-only / Safepair the address with the identifier, then ask the audit log
ADDRESS=aws_security_group.alb_sg
terraform state show "$ADDRESS" | head -20

# Take the identifier from the output above; do not retype it from the ticket.
RESOURCE_ID=sg-0abc123
SINCE=2026-08-12T00:00:00Z    # the last clean detector run

aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=ResourceName,AttributeValue="$RESOURCE_ID" \
--start-time "$SINCE" --max-results 20 --output json \
| jq -r '.Events[] | [.EventTime, .Username, .EventName] | @tsv'

Azure answers the same question through az monitor activity-log list scoped to the resource id, GCP through gcloud logging read filtered on the method name. Whichever cloud, three fields matter and the rest is padding: who the principal was, when the call landed, and which API it called.

Then join it to something outside the cloud, because the audit log cannot answer the question triage is actually asking. Was that identity on call? Does a change record exist for that timestamp? Was a deploy running? Is the identity a person, a pipeline, or a controller that runs continuously? The log says what happened; the join says whether it was meant to.

Step 6: classify, and write down what forced the shape

Every finding is exactly one of four shapes. The discipline is not picking one — it is being able to say what evidence would have moved you to a different one.

ShapeThe evidence that forces itWhat it would take to be wrong
UndesiredAn event exists, an identity is named, and nobody claims the change was intendedAn incident or change record nobody found yet, in which case it is unrecorded intent
Unrecorded intentAn event exists and a change or incident record explains it and the new value is the one the owner wantsThe record explains a different change at a similar time
UninterestingA named system writes this attribute as part of normal operation, and the same address has drifted this way beforeNobody can name the system — in which case it is unknown, not uninteresting
UnknownThe log is empty, the principal is unfamiliar, or the join produced nothingAn audit-log query over a window somebody has not tried yet

The third column is the point of the table. “Uninteresting” is the shape most often assigned on a hunch, and its failure mode is the worst of the four: it ends in a standing exemption, which stops a full plan proposing anything about that attribute on the day it changes for a reason that matters. If nobody can name the system that writes it, the finding is unknown.

Unknown is handled as undesired and raised as a possible access incident. That is slower on purpose.

Step 7: write the finding record

Finding:        DT-2026-08-19-004
Workspace:      prod-network      Commit: 4f0c8ad      CLI: 1.9.8
Exit codes:     refresh=false 0 | refresh-only 2 | full 2  -> the world moved
Address:        aws_security_group.alb_sg
Attribute:      ingress (443/tcp)
Direction:      state has [10.0.0.0/8, 0.0.0.0/0]; world has [10.0.0.0/8]
Proposed:       ~ update in-place  (no replacement)
Audit log:      CloudTrail RevokeSecurityGroupIngress, 2026-08-18T22:14Z,
                arn:aws:iam::111122223333:role/incident-break-glass
                session INC-4392-jdoe. Searched 2026-08-12T00:00Z -> now.
Join:           INC-4392 open, jdoe on call, change record CHG-8871 filed.
Classification: unrecorded intent
Evidence:       break-glass role + open incident + the value the owner wants
Would be wrong if: CHG-8871 turns out to describe a different resource
Owner:          networking team (a.patel)
Recommended:    codify into HCL through a reviewed PR (reconcile runbook, path B)

Every line in that record answers a question a reconciler would otherwise have to re-derive, and the two that are usually missing are the ones that cost the most: the exit-code triple, which is the proof that this is drift at all, and the Would be wrong if line, which is what lets a second reader disagree with you cheaply.

Step 8: hand it on, and stop

Triage ends at the handover. The reconcile runbook picks up from the classification; the drift-incident runbook picks up if something starts being about to apply.

Two things belong in the handover that people leave out. The first is the finding’s priority relative to the others in the queue — a -/+ on a production resource outranks a dozen tag diffs, and the reconciler cannot see the queue you were looking at. The second is the negative result: the addresses you triaged and dismissed, and why. Without it the next detector run produces the same list and somebody triages them all again.

Common patterns

SymptomLikely causeResponse
Full plan is alarming; -refresh-only exits 0The code moved, not the worldNot drift. Send it to whoever owns the unapplied merge
Detector exits 1, never 2Detection is broken — expired credentials, provider error, partial refreshThe estate has had no drift signal since it started. Fix the detector before triaging its output
Terminal shows no drift block; the JSON has resource_driftSince 1.2 a full plan prints only externally-caused changes relevant to its own actionsEnumerate from show -json, or from a refresh-only plan
Dozens of addresses drift at once, all on the same attributeA provider upgrade changed how an attribute is read backStop triaging individually. Read the provider changelog first
The same address drifts every weekAnother system owns that attributeThe finding is the name of that system. Escalate rather than classify it uninteresting
Plan is clean locally, dirty in CIThe two runs are not the same estate — workspace, credentials, commit or CLI version differPut the step 1 coordinates from both runs side by side. Do not triage either output until they match
The audit log has an event but no identity you recogniseA pipeline, a controller, or an unexpected principalUnknown until named. Security decides which, not triage

References

  1. terraform plan — planning modes and options
  2. terraform show
  3. JSON output format — resource_drift
  4. Manage resource drift
  5. terraform#28803 — what the refresh report shows, and why ignore_changes does not affect it
  6. AWS CloudTrail lookup-events API