Drift happens when real infrastructure diverges from your Terraform config, and here is how to spot it and put it back in sync.
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.
The usual suspects are boringly human:
aws CLI command as a hotfix and never brought it back into code.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.
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.
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.
Get comfortable with the symbols, because they tell you what Terraform intends:
~ in-place update-/+ destroy and recreate (replacement)+ create- destroyDrift 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.
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.
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
}
Fixing drift one plan at a time does not scale. Cut it off upstream:
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.
For more failure modes and their fixes, see the Terraform error troubleshooting guide.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A service mesh solves real problems and creates new ones. This is the map: what it actually does, when it earns its cost, and how the options compare.
A "Cycle" error means two Terraform resources depend on each other; here is how to find the loop and break it cleanly.
Explore more articles in this category
Terraform's errors are scarier than the fixes. This is the map to the ones everyone hits: what each message means, the safe way out, and how to avoid losing state.
A practical guide to renaming resources, migrating backends, and splitting or merging Terraform state without destroying and recreating infrastructure.
A practical guide to resolving Terraform provider version conflicts and lock file checksum errors across modules, developers, and CI pipelines.