Skip to main content
Terraform Drift Detection in CI: Catching Out-of-Band Changes Before They Bite

Terraform Drift Detection in CI: Catching Out-of-Band Changes Before They Bite

KU
· Founder & Chief Editor
• 7 min read•Updated •50 views

State drift is silent until a deploy fails or an outage reveals it. The scheduled plan-and-diff pipeline that surfaces console hotfixes and manual edits while they're still cheap to reconcile.

Key takeaways

  • Detect Terraform drift by running terraform plan with -refresh-only and -detailed-exitcode on a schedule in CI.
  • Exit code 2 means real infrastructure changed outside Terraform, so alert with the drifted resource addresses.
  • Then adopt, revert or import every change, keeping drift at zero between deploys so each apply plan contains only intended changes.

Detect Terraform drift in CI by running terraform plan -refresh-only -detailed-exitcode on a schedule. Exit code 0 means state matches reality, 1 means error, and 2 means something changed outside Terraform. Alert on exit 2 with the drifted resource addresses, then adopt, revert or import each change before the next deploy.

Verified against

Terraform 1.17.1 (released 2026-10-28) and hashicorp/setup-terraform v4.0.1, against the developer.hashicorp.com terraform plan command reference, JSON output format and refresh-only drift tutorial. Checked October 2026.

Terraform's promise is that your state file describes reality. Reality disagrees the moment someone fixes something in the console at 2am during an incident, or a different tool touches the same resource, or a provider silently mutates a default. That gap, drift, stays invisible until your next apply either reverts the emergency fix or fails in a confusing way. We built drift detection into CI so drift surfaces in hours, not at the worst possible moment.

The mechanism: refresh-only plan, parse the exit code

terraform plan has a detailed exit code mode that's purpose-built for this:

Bash
terraform plan -detailed-exitcode -refresh-only
# exit 0 = succeeded, empty diff (state matches reality)
# exit 1 = error
# exit 2 = succeeded, non-empty diff (drift detected)

-refresh-only is the important flag: it compares state against the real infrastructure without proposing to change anything from your config. It answers exactly one question, "has reality drifted from what state records?", which is what drift detection is. That is distinct from "does my config differ from state", which is a normal pending change. HashiCorp's docs note the flag needs Terraform v0.15.4 or later, so anything current has it.

.github/workflows/drift.yml
on:
  schedule:
    - cron: "0 */6 * * *"   # every 6 hours

jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_wrapper: false
      # cloud credentials (OIDC role or similar) go here
      - run: terraform init -input=false
      - name: Detect drift
        id: plan
        run: |
          set +e
          terraform plan -detailed-exitcode -refresh-only -no-color -out=drift.tfplan > plan.txt 2>&1
          echo "exit=$?" >> "$GITHUB_OUTPUT"
      - name: Fail on error
        if: steps.plan.outputs.exit == '1'
        run: cat plan.txt && exit 1
      - name: Alert on drift
        if: steps.plan.outputs.exit == '2'
        run: ./notify-drift.sh drift.tfplan

Treat exit 1 as its own alert. A drift job that silently errors every six hours (expired credentials are the classic cause) looks exactly like a clean one if you only watch for exit 2.

Schedule it; don't wait for a deploy

The whole point is to decouple detection from deployment. If you only learn about drift when someone runs apply, you learn about it at the moment it's most disruptive: mid-deploy, with a reviewer staring at a plan full of surprising reverts. A scheduled check (every few hours) catches drift while the person who caused it still remembers doing it.

Make the alert actionable, not noisy

A raw plan diff is unreadable in an alert. Extract the resource addresses that drifted, then find who or what likely touched them. The saved plan's JSON form has a resource_drift array built for this, which is sturdier than grepping human-readable output:

Bash
terraform show -json drift.tfplan | jq -r '.resource_drift[].address'
aws_db_parameter_group.prod_pg
aws_security_group.api

Pair this with CloudTrail or audit-log lookups for those resources to attribute the change. "RDS parameter group prod-pg was modified by console:alice@ at 02:14" is an actionable alert. "Plan shows 47 changes" is noise people learn to ignore.

Detection needs a reconciliation policy

Detecting drift is half the job. The team needs a decided answer for each drift:

  • Adopt it: the out-of-band change was correct, so update the Terraform config to match and the next apply doesn't revert it. (Most common for legitimate hotfixes.)
  • Revert it: the change was unauthorized or wrong, so let Terraform restore the declared state.
  • Import it: a resource was created outside Terraform, so bring it under management with an import block or terraform import. Our guide to importing existing resources into Terraform covers both.

The anti-pattern is detecting drift and doing nothing, because then your next real deploy carries a pile of unrelated reverts and the reviewer can't tell intended changes from accidental ones. Drift should be reconciled to zero between deploys, so every apply plan contains only what that deploy intends.

Guardrails that reduce drift at the source

Detection is reactive; these reduce how often drift happens at all:

  • Restrict console write access in production. Read-only by default; break-glass roles for emergencies that are audited and reviewed.
  • ignore_changes on attributes legitimately mutated outside Terraform (e.g. autoscaling-managed desired counts), so they don't register as drift forever. prevent_destroy is a different guard: it blocks accidental deletion, not drift noise.
  • One owner per resource. Drift is often two tools fighting over the same resource. Decide which system owns it.

What we got out of it

  • Median time-to-detect for drift went from "next deploy, days later" to under 6 hours, which is simply the schedule interval.
  • The "surprise revert" class of incident, a deploy quietly undoing an emergency fix, stopped, because hotfixes get adopted into config within a day.
  • Plans got readable again: because drift is reconciled continuously, deploy-time plans show only the intended change instead of an archaeology of accumulated console edits.

Questions people ask

What does terraform plan -detailed-exitcode return?

Three codes. 0 means the plan succeeded with an empty diff, 1 means an error, and 2 means the plan succeeded with a non-empty diff. Combined with -refresh-only, exit 2 specifically means real infrastructure no longer matches state. In CI, disable set -e around the command, capture $?, and branch on it, because most shells treat any non-zero code, including the expected 2, as a failure.

What is the difference between a refresh-only plan and a normal plan?

A normal plan compares your configuration to state refreshed from real infrastructure and proposes actions to make reality match the code. A refresh-only plan only reports how real infrastructure differs from the recorded state and, if applied, updates state to match without changing any resources. For drift detection you want the refresh-only question: did something change outside Terraform?

How often should Terraform drift detection run?

Often enough that the person who made the change still remembers it. Every few hours to daily covers most teams; we run every six hours. Each run refreshes every resource, so very large states can hit provider API rate limits. Split big root modules into separately scheduled jobs rather than running one enormous refresh every hour.

The call we'd make

Drift detection isn't about preventing manual changes; sometimes the 2am console fix is exactly right. It's about making sure those changes get noticed and folded back into code before Terraform and reality diverge far enough to cause an outage. Schedule a refresh-only plan, alert on exit 2 and exit 1 separately, and reconcile to zero between deploys. Once a drift check flags something, you still need to decide what to do about it: revert, adopt into config, or import. We walk through those resolution paths in how to detect and fix Terraform drift.

Sources

React
Share this post
Cite this article
Plain text

Kiril Urbonas (2026). Terraform Drift Detection in CI: Catching Out-of-Band Changes Before They Bite. DevOpsNess. Updated 2026-10-31. https://www.devopsness.com/blog/terraform-drift-detection-in-ci

Markdown

[Terraform Drift Detection in CI: Catching Out-of-Band Changes Before They Bite](https://www.devopsness.com/blog/terraform-drift-detection-in-ci) (Kiril Urbonas, DevOpsNess, updated 2026-10-31)

KU

Kiril Urbonas

Founder & Chief Editor

590 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.