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 Terraform | Detected by a plan | Why |
|---|---|---|
| An attribute of a managed resource is edited | Yes | The refreshed value differs from the configuration |
| A managed resource is deleted | Yes | The refresh finds nothing, so the plan proposes to create it |
| A new resource is created in the console | No | It is not in state, so Terraform never reads it |
An attribute listed in ignore_changes is edited | No | You told Terraform not to compare it |
| An attribute the provider does not track is edited | No | The 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:
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:
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:
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:
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.
| Situation | Resolution | How |
|---|---|---|
| The live change was a mistake or a leftover from debugging | Revert it | Run terraform apply. The plan already shows the revert |
| The live change was right, and the code is out of date | Adopt it | Edit the configuration to match, then confirm the plan is empty |
| The attribute is meant to change at runtime, such as an autoscaled count | Stop comparing it | Add 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.
| Approach | How it detects drift | Sees unmanaged resources | Worth knowing |
|---|---|---|---|
terraform plan in CI | Scheduled plan with -detailed-exitcode | No | Free and transparent. You build the alerting and the follow-up |
| HCP Terraform health assessments | Scheduled refresh-only checks per workspace | No | Part of the Standard and Premium editions at the time of writing, and of Terraform Enterprise |
| Terraform automation platforms such as Spacelift, env0, and Scalr | Scheduled plans per stack or environment | No | Results sit next to the run history. Some can trigger a reconciling run |
| driftctl | Compares state files with the provider API | Yes | In maintenance mode. Not a choice for new adoption |
| Provider configuration recording, such as AWS Config | Records every configuration change | Yes | Knows what changed, not what your code intended |
| ops0 | Continuous discovery of live accounts compared with infrastructure as code | Yes | Shows 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.
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.
- 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.