A security group rule in Terraform code compared with the wider rule found in the cloud account, which a plan reports as drift with exit code 2.
Terraform

Terraform Drift Detection: How to Find Changes Made Outside Your Code

Terraform drift detection comes down to one command run on a schedule. This guide covers the exit codes, the refresh-only plan, a CI workflow that opens an issue when drift appears, what to do with the result, and the changes Terraform cannot see.

Reviewed for technical accuracy: November 12, 2026

  • terraform plan -detailed-exitcode exits 0 for no differences, 1 for an error, and 2 for differences, which makes drift detection scriptable
  • Run the check against the last applied commit, or exit code 2 reports unapplied code changes instead of drift
  • A plan only sees resources in state: anything created in the console and never imported is invisible to it
  • Detect drift automatically, but have a person choose between reverting, adopting, or ignoring each difference

Terraform drift detection means comparing what your Terraform configuration and state say exists with what the cloud provider reports, and flagging every difference. The built-in way to do it is terraform plan -detailed-exitcode, which exits with code 2 when live infrastructure no longer matches the code. Run that command on a schedule against each workspace and you have drift detection.

This guide is the how-to: the commands, the exit codes, a CI workflow you can copy, how to respond when drift is found, and the kinds of change a plan can never see. For the background on why drift happens and what it costs, see what infrastructure drift is and how to fix it. Everything here applies to OpenTofu as well; replace terraform with tofu.

What Terraform can and cannot detect

A plan refreshes each resource in state by reading it from the provider, then compares the result with the configuration. That defines exactly what it can notice.

Change made outside TerraformDetected by a planWhy
An attribute of a managed resource is editedYesThe refreshed value differs from the configuration
A managed resource is deletedYesThe refresh finds nothing, so the plan proposes to create it
A new resource is created in the consoleNoIt is not in state, so Terraform never reads it
An attribute listed in ignore_changes is editedNoYou told Terraform not to compare it
An attribute the provider does not track is editedNoThe provider never reads it back

The third row is the important one. A clean plan means the resources Terraform manages match the code. It says nothing about resources Terraform has never heard of, which is where most console-created infrastructure lives.

Detect drift with terraform plan

By default terraform plan exits 0 whether or not it found changes, which is no use to a script. The -detailed-exitcode flag makes the result machine-readable:

BASH
terraform init -input=false
terraform plan -detailed-exitcode -input=false
echo "exit code: $?"
  • 0: the plan succeeded and found no differences. Live infrastructure matches the code.
  • 1: the plan failed. Credentials, a provider error, or invalid configuration. This is not drift, and it must not be reported as drift.
  • 2: the plan succeeded and found differences.

Exit code 2 has two possible causes, and only one of them is drift. It also fires when the code contains a change that has not been applied yet. To measure drift alone, run the check against the commit that was last applied, which for most teams means the default branch after the deploy pipeline has finished. A drift job that runs against an open pull request reports the pull request, not drift.

See what changed with a refresh-only plan

A normal plan shows what Terraform would do to bring infrastructure back to the code. It mentions out-of-band changes only where they affect that proposal. To see every difference between state and reality, without any proposed action, use a refresh-only plan:

BASH
terraform plan -refresh-only

The output lists each managed object whose live attributes differ from what state recorded, under the heading "Objects have changed outside of Terraform". Nothing is proposed for creation, change, or deletion. It is the cleanest way to answer the question "what did someone touch?"

Two cautions. First, terraform apply -refresh-only writes the drifted values into state, which accepts the drift as the new baseline. Use it only after deciding that the live value is the one you want, and follow it with a code change so the configuration agrees. Second, -refresh-only can be combined with -detailed-exitcode, but confirm on your version that the exit code behaves as expected before a pipeline depends on it.

Run the check on a schedule

Drift detection is the plan command plus a timer plus somewhere to send the result. This GitHub Actions workflow runs daily, signs in with a read-only role, and opens an issue when the exit code is 2:

YAML
name: drift-detection
on:
  schedule:
    - cron: "0 6 * * *"   # daily, 06:00 UTC
  workflow_dispatch:

permissions:
  contents: read
  id-token: write   # OIDC sign-in to the cloud provider
  issues: write     # open an issue when drift is found

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_wrapper: false   # keep the real exit code
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-drift-readonly
          aws-region: us-east-1
      - name: Plan
        id: plan
        run: |
          terraform init -input=false
          set +e
          terraform plan -detailed-exitcode -input=false -no-color -lock=false > plan.txt
          echo "exitcode=$?" >> "$GITHUB_OUTPUT"
      - name: Fail on error
        if: steps.plan.outputs.exitcode == '1'
        run: cat plan.txt && exit 1
      - name: Report drift
        if: steps.plan.outputs.exitcode == '2'
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          tail -c 60000 plan.txt > body.txt
          gh issue create --title "Terraform drift detected: production" --body-file body.txt

Four details in that workflow matter more than the rest:

  • terraform_wrapper: false. The setup action wraps the Terraform binary by default. Turning the wrapper off means $? holds the real exit code, which the next line saves as a step output.
  • set +e. Actions runs shell steps with exit-on-error. Without this line the step would stop at exit code 2 and the drift would be reported as a failed job.
  • A read-only role and -lock=false. A drift check should not be able to change anything, so its role has read access to the resources and the state file and nothing else. A read-only role cannot take the state lock, hence -lock=false. The trade-off is that a check running during an apply can read a half-updated state, so schedule it outside deploy windows and treat a single odd result as a prompt to run it again.
  • The plan output is sensitive. It contains resource names, addresses, and configuration. Values marked sensitive are masked, but everything else is readable by anyone who can read the issue. Send it to a private repository or a restricted channel, and trim it, as the last step does, because issue bodies have a size limit.

The same shape works in any scheduler. In GitLab CI, use a scheduled pipeline with allow_failure: exit_codes: 2. In Jenkins or a plain cron job, capture $? and branch on it. One job per workspace keeps a failure in one from hiding drift in another.

What to do when drift is found

A drift alert is a question, not an instruction. The plan shows what differs. It does not show who changed it or whether the change was right. Start by finding out:

BASH
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=ResourceName,AttributeValue=sg-0a1b2c3d \
  --max-results 10 \
  --query 'Events[].[EventTime,Username,EventName]' --output table

With the who and the when in hand, there are three ways to resolve the difference, and choosing between them is a judgment call that belongs to the resource owner.

SituationResolutionHow
The live change was a mistake or a leftover from debuggingRevert itRun terraform apply. The plan already shows the revert
The live change was right, and the code is out of dateAdopt itEdit the configuration to match, then confirm the plan is empty
The attribute is meant to change at runtime, such as an autoscaled countStop comparing itAdd it to lifecycle { ignore_changes } with a comment explaining why

Do not apply the revert automatically. The most common source of drift in production is an engineer fixing an incident at 2 a.m. An unattended job that reverts the fix a few hours later re-creates the incident. Automatic detection with reviewed remediation is the safe default; automatic remediation is for the narrow set of resources where the code is always right.

Use ignore_changes sparingly. Each entry is a deliberate blind spot, and a long list of them means the drift check passes while the infrastructure moves freely.

The gap: resources Terraform never managed

Scheduled plans catch drift in what Terraform manages. They cannot catch a security group, bucket, or database that was created in the console and never imported, because it appears in no state file. In accounts with a history of ClickOps, that unmanaged estate is often larger than the managed one.

Closing the gap takes a second comparison: the full inventory of the cloud account against the set of resources in state. The difference is the list of unmanaged resources. Each one then gets a decision, usually to bring it under code with an import block or to delete it. Generating Terraform from existing AWS resources walks through the import route.

Drift detection tools compared

A cron job running the plan command is enough for a handful of workspaces. Past that, the work shifts to scheduling, credentials, routing alerts, and tracking what was done about each one, which is what the tools below take on.

ApproachHow it detects driftSees unmanaged resourcesWorth knowing
terraform plan in CIScheduled plan with -detailed-exitcodeNoFree and transparent. You build the alerting and the follow-up
HCP Terraform health assessmentsScheduled refresh-only checks per workspaceNoPart of the Standard and Premium editions at the time of writing, and of Terraform Enterprise
Terraform automation platforms such as Spacelift, env0, and ScalrScheduled plans per stack or environmentNoResults sit next to the run history. Some can trigger a reconciling run
driftctlCompares state files with the provider APIYesIn maintenance mode. Not a choice for new adoption
Provider configuration recording, such as AWS ConfigRecords every configuration changeYesKnows what changed, not what your code intended
ops0Continuous discovery of live accounts compared with infrastructure as codeYesShows what depends on the drifted resource and routes the fix through a reviewed change

The column that separates these is the third one. Plan-based tools answer "does what we manage still match the code?" Inventory-based tools also answer "what exists that we do not manage?" Most teams need both answers.

Reducing drift at the source

Detection is the backstop. The volume of drift is set by how easy it is to change production without going through code.

  • Make the pipeline the easy path. If a reviewed change takes ten minutes, few people reach for the console. If it takes a day, they will.
  • Remove standing write access to production. Day-to-day roles read; the pipeline writes. Engineers keep the ability to act in an emergency through a break-glass role that is logged and alerts when used.
  • Reconcile every emergency change. A break-glass session ends with a pull request that either adopts or reverts what was done. The drift check is what confirms it happened.
  • Detect continuously, not weekly. The longer drift stays, the more other changes are built on top of it and the harder it is to revert.

ops0 runs this loop as one workflow. Drift prevention in ops0 compares live cloud state with infrastructure as code continuously, including resources no code manages, shows the dependencies of each drifted resource, and turns the chosen resolution into a change that goes through plan, policy, and approval like any other.

Quick answers

How do I detect drift in Terraform?

Run terraform plan -detailed-exitcode against the commit that was last applied. Exit code 0 means live infrastructure matches the code, 1 means the plan failed, and 2 means there are differences. Scheduling that command in CI for each workspace gives you continuous drift detection.

What does terraform plan -refresh-only do?

It reads every managed resource from the provider and reports where live attributes differ from what state recorded, without proposing to create, change, or destroy anything. It is the clearest view of what was changed outside Terraform. Applying a refresh-only plan writes those live values into state, which accepts the drift.

Does Terraform detect resources created manually in the console?

No. Terraform only reads resources that are in its state. A resource created in the console and never imported does not appear in any plan. Finding those requires comparing a full inventory of the cloud account with the resources in state.

Should drift be fixed automatically?

Detect it automatically and fix it deliberately. Drift in production is often an emergency fix, and reverting it without review can bring the incident back. Reserve automatic remediation for resources where the code is always the correct answer.

How often should Terraform drift detection run?

At least daily for production workspaces, and more often where console access is common. A plan is read-only, so the cost is runner time and provider API calls. The longer drift goes unnoticed, the more later changes depend on it.

Does drift detection work the same way in OpenTofu?

Yes. tofu plan supports -detailed-exitcode and -refresh-only with the same meaning, so the commands and the CI workflow carry over by replacing terraform with tofu.

Sources
  • terraform plan command: HashiCorp. The -detailed-exitcode and -refresh-only options and their exit codes.
  • Manage resource drift: HashiCorp. Using refresh-only plans to review and accept or revert drift.
  • Health assessments: HCP Terraform documentation. Scheduled drift detection and the editions that include it.
  • tofu plan command: OpenTofu documentation. The same planning options in OpenTofu.
  • driftctl: Snyk on GitHub. Project README, including its maintenance-mode status.
From article to workflow

Move from a drift finding to a reviewed change.

Find the resource, confirm the intended configuration, review the plan, and retain the evidence after the approved fix.

Related articles

All articles