How to Detect and Fix Terraform Drift
Drift happens when real infrastructure diverges from your Terraform config, and here is how to spot it and put it back in sync.
Key takeaways
Drift happens when real infrastructure diverges from your Terraform config, and here is how to spot it and put it back in sync.
How to Detect and Fix Terraform Drift#
You run terraform plan expecting "No changes." Instead Terraform wants to modify a security group you never touched, or recreate an instance that looks fine. That gap between what your .tf files say and what actually exists in the cloud is drift, and it is one of the most common ways a calm Terraform workflow turns into a confusing one.
Drift is not a Terraform bug. It means something changed your infrastructure outside of Terraform. Terraform tracks the world through its state file, and state only updates when Terraform runs. Anything that edits the real resource without going through Terraform leaves state stale and config out of sync with reality.
What actually causes drift#
The usual suspects are boringly human:
- Someone opened the AWS console and clicked a setting to fix an incident at 2am.
- Another tool (an autoscaler, a controller, a backup job) changed a tag or a rule.
- A teammate ran an
awsCLI command as a hotfix and never brought it back into code. - A provider default changed, or the cloud mutated a computed attribute.
None of these are malicious. They are just changes Terraform did not make, so it has no idea they happened until it next compares config against the live API.
How drift shows up#
Drift surfaces as an unexpected diff in terraform plan. You did not edit the config, but Terraform still proposes changes. A plan that reads like this is the tell:
terraform plan
# aws_security_group.web will be updated in-place
~ resource "aws_security_group" "web" {
id = "sg-0abc123"
~ ingress = [
- {
- cidr_blocks = ["0.0.0.0/0"]
- from_port = 22
- to_port = 22
- protocol = "tcp"
},
]
}
Terraform wants to remove an SSH rule you never added in code. That rule exists because someone opened port 22 in the console. Config says "no such rule," reality says "rule present," and the plan is Terraform offering to reconcile the two.
Detecting drift deliberately#
Discovering drift during a real change is a bad time to find it. Detect it on purpose instead.
A plain terraform plan already refreshes state against the live API and shows any drift mixed in with your intended changes. To see drift on its own, with no pending config changes in the way, use a refresh-only plan:
terraform plan -refresh-only
This asks a single question: has anything changed in the real world since state was last written? It reports differences between state and reality without proposing to apply your config. It is the cleanest read on "what drifted."
A note on terraform refresh: the standalone command still exists but is deprecated because it mutates state as a side effect with no plan to review first. Prefer terraform plan -refresh-only (and its apply counterpart below) so you always see the change before it is written.
Reading the plan diff#
Get comfortable with the symbols, because they tell you what Terraform intends:
~in-place update-/+destroy and recreate (replacement)+create-destroy
Drift caused by a small attribute change usually shows as ~. Drift where the resource was rebuilt with a new identity often shows as -/+. The symbol tells you which resolution path you are on.
Resolving drift by intent#
There is no single fix. The right move depends on what you want the end state to be.
Revert reality to match config. The console change was wrong or unauthorized, and code is the source of truth. Just apply. Terraform will undo the out-of-band change and bring the resource back to what your .tf files declare:
terraform apply
In the security group example above, this deletes the rogue port 22 rule.
Accept the real change into config. The manual change was correct and you want to keep it. Edit the .tf to describe the new reality, then plan until it is clean. Add the ingress block that someone created by hand, run terraform plan, and confirm it now reports no changes. Nothing is applied because config and reality already agree.
Update state without touching infrastructure. Sometimes the live change is fine and you only need state to catch up (for example a computed value the provider adjusted). A refresh-only apply writes the observed values into state without changing any real resource:
terraform apply -refresh-only
The resource was recreated or replaced outside Terraform. Someone deleted the old instance and built a new one by hand. State still points at the dead resource, so Terraform wants to create yet another. Bring the existing resource under management with an import instead:
terraform import aws_instance.web i-0new123456
On Terraform 1.5+ you can also declare an import block in config and review it in a plan before applying, which is safer for anything you do more than once.
Something was deleted outside Terraform. A resource in state no longer exists. If you want it back, terraform apply recreates it from config. If it was meant to be gone, drop it from state so Terraform stops tracking it:
terraform state rm aws_instance.web
state rm only forgets the resource; it never deletes anything real.
Two blocks that keep drift quiet#
Some drift is expected and repeating. lifecycle { ignore_changes } tells Terraform to stop fighting over attributes that another system legitimately owns, like a tag an autoscaler stamps on:
resource "aws_autoscaling_group" "app" {
# ...
lifecycle {
ignore_changes = [tag, desired_capacity]
}
}
And when you refactor or rename resources, a moved block preserves the mapping so Terraform updates addresses in state instead of proposing a destroy and recreate that reads like drift:
moved {
from = aws_instance.web
to = aws_instance.web_server
}
Preventing drift#
Fixing drift one plan at a time does not scale. Cut it off upstream:
- Least privilege: humans should not be able to click-change prod. Restrict console write access so changes have to go through code and review.
- Policy: enforce that infrastructure changes land as pull requests, with break-glass access logged and reconciled afterward.
- Continuous detection: run refresh-only plans on a schedule so drift is caught in hours, not during the next deploy. That is exactly what a pipeline is for, and we cover the setup in drift detection in CI.
The call we'd make#
Detect drift on a schedule with terraform plan -refresh-only so you are never surprised by it mid-change. When drift appears, decide intent first: revert with apply, absorb it into config, import a replaced resource, or reconcile state with state rm. Reach for ignore_changes only on attributes another system genuinely owns, and lock down console access so drift becomes the exception instead of the routine.
This post is about resolving drift once you've found it; for wiring the detection itself into a scheduled pipeline so you catch it within hours instead of at the next deploy, see Terraform drift detection in CI.
For more failure modes and their fixes, see the Terraform error troubleshooting guide.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Container Vulnerability Scanning: Trivy vs Grype vs Snyk
A practitioner's comparison of Trivy, Grype, and Snyk for finding CVEs in container images, plus how to wire scanning into CI without drowning in noise.
Fix the "Cycle" Dependency Error in Terraform
A "Cycle" error means two Terraform resources depend on each other; here is how to find the loop and break it cleanly.
More from DevOps
Explore more articles in this category
Best Log Management Tools in 2026: What You Actually Pay For
Every log platform looks affordable at proof-of-concept volume and expensive at production volume. The pricing model, not the feature list, decides which one you can live with.
Best Managed Kubernetes in 2026: EKS vs GKE vs AKS vs DOKS
The control plane fee is the least interesting number. What separates managed Kubernetes providers is upgrade cadence, how much they run for you, and where the node bill lands.
Your CI Runner Is the Target: Hardening Against npm Worms
The keyv compromise reached 444 packages and over two billion monthly installs through preinstall scripts. The controls that actually stop it are boring and mostly free.
You might have missed
Evergreen posts worth revisiting.