Infrastructure drift detection is the practice of checking, on a schedule, whether the resources running in your cloud account still match what Terraform believes it created. Someone widened a security group during an incident. An autoscaling policy was tuned in the console. A provider upgrade changed a default. None of it went through a pull request, so none of it is in your code.
Most teams find out during an unrelated deployment, when a plan they expected to be empty proposes to destroy something. This guide covers what drift is, why terraform plan is not the same as detecting it, how to schedule real checks, and what to do with each finding.
What Infrastructure Drift Is and Why It Happens
Terraform holds three separate pictures of your infrastructure:
- Configuration — your
.tffiles: what you have declared. - State —
terraform.tfstate: what Terraform recorded at the last apply. - Reality — the resources actually running in AWS, Azure or GCP right now.
Drift is a gap between the second and third. Uncommitted code changes are a gap between the first and second — a different problem with a different fix, and a common source of confusion when teams start measuring this.
Drift is rarely malice. It comes from a short list of repeatable causes:
| Cause | Example | Frequency |
|---|---|---|
| Incident response | Ingress rule opened at 2am, never reverted | Very common |
| Console convenience | Instance size or RDS parameter changed in the UI | Very common |
| Provider defaults | An upgrade changes a computed default | Occasional |
| Cloud-side changes | Managed service auto-upgrades a minor version | Occasional |
| Other automation | An autoscaler or operator mutates a Terraform-owned resource | Common in Kubernetes estates |
| Partial applies | An apply errors halfway, leaving state and reality out of step | Occasional |
Two consequences matter more than the untidiness. The next apply becomes unpredictable, because Terraform will try to reverse a deliberate change and may replace the resource to do it. And your code stops being evidence: if an auditor asks what your production network allows and the answer is "read the Terraform", that is only true if drift is being checked — the same reasoning behind treating configuration as evidence in DevSecOps pipelines.
Why Terraform plan Alone Is Not Drift Detection
terraform plan refreshes state by default, so it does surface drift — but as a side effect, buried in a diff you are reading for another reason. That fails as a detection mechanism for four reasons.
It only runs when someone deploys. A workspace nobody has touched for six weeks has had no drift check for six weeks, and quiet infrastructure is where drift accumulates unnoticed.
Speed optimisations switch it off. Teams add -refresh=false to make CI faster, or -target to narrow a plan. Both skip the comparison that finds drift. Worth grepping your pipelines for today.
Drift and intent are tangled together. When a plan shows twelve changes, nobody separates the ones they wrote from the ones someone made in the console. The reviewer approves the lot.
It reports only what you declared. HashiCorp's documentation is explicit that drift detection covers the attributes present in your configuration. An attribute you never set, and a resource created outside Terraform, are both invisible to it — and unmanaged resources usually need a separate control such as AWS Config.
The distinction to hold onto: a plan tells you what will happen if you apply. Drift detection tells you what already happened without you.
How to Detect Drift: Scheduled Plans, Refresh-Only Runs and Tooling
Detection has to be scheduled, read-only, and visible enough that someone reads the result.
Use refresh-only runs, not full plans
Since Terraform 0.15.4, -refresh-only gives you a mode built for this:
terraform plan -refresh-only -detailed-exitcodeIt compares state against the real world without proposing configuration changes, so the output is drift and nothing else. -detailed-exitcode returns 0 for no changes and 2 when there is something to report, which is what you branch on in CI. Never combine it with -refresh=false, and never chain it to an automatic apply.
Schedule it per workspace
A scheduled pipeline is enough to start — nightly for production, weekly for lower environments:
# .github/workflows/drift.yml
on:
schedule:
- cron: '0 2 * * *'
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- id: check
run: terraform plan -refresh-only -detailed-exitcode -no-color
continue-on-error: true
- if: steps.check.outputs.exitcode == '2'
run: ./notify-slack.sh # alert the owning teamTwo details decide whether this survives contact with a real estate: give the detection role read-only credentials, and route findings per workspace to the owning team rather than one shared channel, or they become background noise within a fortnight.
Managed drift detection
HCP Terraform health assessments do this natively. They run roughly every 24 hours, need Terraform 1.3.0+ for the full feature set, require remote or agent execution mode, and only run where the last apply succeeded. Results appear in the UI and through the assessment results API, so drift status can feed an existing dashboard. The feature sits in the paid editions, so check pricing first.
Spacelift, env0 and similar platforms offer equivalent scheduled checks. One caveat: driftctl, the tool most older articles recommend, is no longer maintained — it moved to maintenance mode and the repository was archived in December 2025. Do not build a new control on it.
Find the cause, not just the diff
A drift report tells you what changed. An audit trail tells you who and why — query CloudTrail, Azure Activity Log or GCP Cloud Audit Logs for the resource ID since your last clean check. If drift keeps reappearing in one workspace, the real problem is usually console access that should have been removed, which is a cloud security question rather than a Terraform one.
How to Remediate: Import, Adopt, Revert or Ignore
There is no default correct action. Ask one question of each finding — was the change out there a good idea? — and the path follows:
| Situation | Action | How |
|---|---|---|
| Change was wrong or unauthorised | Revert | Run a normal terraform apply to push the declared config back |
| Change was right and should stay | Adopt | Edit the .tf files to match reality, then apply so the diff clears |
| Resource exists, Terraform doesn't know it | Import | Add an import block, plan, review, apply |
| Attribute is legitimately managed elsewhere | Ignore | Add it to lifecycle { ignore_changes = [...] } |
| Resource should leave Terraform's control | Release | Use a removed block to drop it from state without destroying it |
Reverting is the one to be careful with. Confirm the change is not a live workaround for an open incident, and check whether reverting forces a replacement — read the plan for -/+ markers, not just the change count. Adopting is often the honest answer: if someone raised a memory limit at 3am and the service has been stable since, the code was wrong.
For import, use the config-driven form introduced in Terraform 1.5 rather than the older CLI command, because it goes through plan and review like any other change:
import {
to = aws_security_group.api
id = "sg-0a1b2c3d4e5f"
}Use ignore_changes sparingly and comment why. It is right for tags applied by a cost platform or replica counts owned by an autoscaler, and wrong for silencing a finding nobody wants to investigate.
Remediation speed matters more than elegance. Drift left open for a month becomes indistinguishable from intent, and the code quietly loses its authority. Teams that keep drift below a working week have per-workspace ownership and a pipeline where the fix is a small pull request — both products of the same CI/CD engineering work as the rest of your delivery path.
Getting Drift Under Control
If you cannot say when your production workspaces were last checked against reality, they were checked at the last deploy. Start narrowly:
- Grep pipelines for
-refresh=falseand-target, and remove them from anything you rely on for correctness. - Add a nightly
-refresh-onlyjob with read-only credentials, production first. - Give every workspace a named owner and route its findings there.
- Agree a remediation window — a week is a reasonable target — and record which action was taken and why.
Drift detection is not a tool you buy once. It is a control that keeps running, with someone accountable for what it reports.
To have that control designed and wired into your pipelines properly — state layout, scheduled checks and remediation runbooks — explore our Terraform Consulting Services or talk to our team. We usually start by measuring how far your live environment has already moved from your code.
Related reading: platform engineering services for the ownership model that makes drift someone's job, and GitOps consulting for the same problem inside Kubernetes.