# How to Detect and Fix Terraform Drift

URL: https://www.devopsness.com/blog/fix-terraform-drift
Author: Kiril Urbonas
Published: 2026-07-31
Updated: 2026-10-31
Categories: DevOps
Tags: Terraform, Iac, Drift, Troubleshooting, DevOps

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

To fix Terraform drift, run `terraform plan -refresh-only` to see what changed outside Terraform, then pick a resolution by intent: `terraform apply` to revert reality to your config, edit the `.tf` files to accept the change, `terraform import` a resource rebuilt by hand, or `terraform state rm` one deleted on purpose.

> [!VERIFIED]
> Terraform CLI 1.17 (1.17.1, released 2026-10-28), against the developer.hashicorp.com docs for `plan`, `refresh`, `import` blocks and the `lifecycle` meta-argument. Checked October 2026.

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.

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:

```
$ 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 the docs mark it deprecated because it writes to state 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
```

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. Running `terraform plan -generate-config-out=generated.tf` writes the matching `resource` block for you:

```hcl
# imports.tf
import {
  to = aws_instance.web
  id = "i-0new123456"
}
```

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

```hcl
# asg.tf
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
# moved.tf
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. Add `-detailed-exitcode`, which exits `2` when the plan has changes, so the job can alert. That is exactly what a pipeline is for, and we cover the setup in [drift detection in CI](/blog/terraform-drift-detection-in-ci).

If drift is constant because many hands touch the same cloud, a continuously reconciling control plane is the other answer; the tradeoffs are in [Crossplane vs Terraform](/blog/crossplane-vs-terraform).

## Questions people ask

### What causes Terraform drift?

Any change made outside Terraform: a console edit during an incident, an `aws` CLI hotfix, an autoscaler or controller rewriting tags, or a provider default that changed. Terraform only updates state when it runs, so it notices none of these until the next plan compares state against the live API. Drift is a process problem, not a Terraform bug.

### Is terraform refresh deprecated?

Yes. HashiCorp's docs mark `terraform refresh` deprecated and recommend `terraform plan -refresh-only` and `terraform apply -refresh-only` instead. The old command writes detected changes straight to state with nothing to review. The refresh-only plan shows the same differences first, and the refresh-only apply writes them only after you approve, so a surprise deletion cannot silently disappear from state.

### Does terraform apply -refresh-only change my infrastructure?

No. A refresh-only apply updates the state file to match what currently exists and leaves every real resource untouched. Use it when the live change is correct and you only want state to catch up. If you also want config to match, edit the `.tf` files afterwards, otherwise the next normal plan will propose reverting the change.

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

For wiring detection into a scheduled pipeline, see [Terraform drift detection in CI](/blog/terraform-drift-detection-in-ci); for other failure modes, the [Terraform error troubleshooting guide](/blog/terraform-error-troubleshooting-guide).

## Sources

- [Terraform docs: terraform plan command](https://developer.hashicorp.com/terraform/cli/commands/plan)
- [Terraform docs: terraform refresh command (deprecated)](https://developer.hashicorp.com/terraform/cli/commands/refresh)
- [Terraform docs: Import existing resources with import blocks](https://developer.hashicorp.com/terraform/language/import)
- [Terraform docs: The lifecycle meta-argument](https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle)

