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

URL: https://www.devopsness.com/blog/terraform-drift-detection-in-ci
Author: Kiril Urbonas
Published: 2026-06-24
Updated: 2026-10-31
Categories: Infrastructure
Tags: Terraform, Iac, Drift, Automation, CI/CD

Summary: 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.

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

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]
> 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.

```yaml
# .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](/blog/terraform-import-existing-resources) 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](/blog/fix-terraform-drift).

## Sources

- [Terraform CLI: terraform plan command reference](https://developer.hashicorp.com/terraform/cli/commands/plan)
- [Terraform: JSON output format (resource_drift)](https://developer.hashicorp.com/terraform/internals/json-format)
- [HashiCorp tutorial: Use refresh-only mode to sync Terraform state](https://developer.hashicorp.com/terraform/tutorials/state/resource-drift)
- [Terraform v1.17.1 release notes (GitHub)](https://github.com/hashicorp/terraform/releases/tag/v1.17.1)

