Terraform Drift Detection in CI: Catching Out-of-Band Changes Before They Bite
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.
On this page
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:
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.
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:
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
importblock orterraform 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_changeson attributes legitimately mutated outside Terraform (e.g. autoscaling-managed desired counts), so they don't register as drift forever.prevent_destroyis 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
Cite this article
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
[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)
More from Infrastructure
Explore more articles in this category
50 Terraform Lessons Most Teams Learn Too Late
Terraform codebases rarely fail on day one. They fail on day 400. Fifty lessons from Terraform in Depth, fact-checked against current Terraform and OpenTofu, with code and a 20-point checklist.
Terraform Plan Wants to Recreate Your Production Database? Read This First
The plan says -/+ next to your RDS instance and one attribute ends in "forces replacement". Why it happens, how to undo it without data loss, and the CI gate that stops it reaching apply.
How to Stop Anyone From Deleting Your Production Database
GitLab lost six hours of data to one command on the wrong host. Linear lost five hours to one TRUNCATE CASCADE in a reviewed migration. The six guardrails that make that kind of mistake hard to make and cheap to recover from.
You might have missed
Evergreen posts worth revisiting.