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 aws CLI 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:

bash.bash

terraform plan

hcl.hcl

# 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:

bash.bash

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:

bash.bash

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:

bash.bash

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:

bash.bash

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:

bash.bash

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:

hcl.hcl

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:

hcl.hcl

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.