Terraform Drift Detection and Refresh-Only Mode

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
kreuzwerker/docker 3.9.0
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform and Docker — Terraform lab environment on Ubuntu
Privilege Normal user (Docker socket access required)
Scope Terraform drift detection — out-of-band infrastructure changes, normal terraform plan, terraform plan -refresh-only, terraform apply -refresh-only, configuration vs state vs remote distinction, deprecated terraform refresh, brief ignore_changes comparison, external deletion scenario, and common mistakes. Does not cover full plan flag reference, state subcommand catalog, HCP drift configuration, lifecycle tutorial depth, or production monitoring platforms.
Related guides Terraform state locking
terraform state commands
Terraform Associate certification course

Terraform drift is the gap between what your .tf files declare and what actually exists after someone changes infrastructure outside Terraform. Refresh-only mode lets you update state to match observed reality without pushing configuration back onto the remote object — or running a normal apply that would.

text
Terraform creates resource
Someone changes resource outside Terraform
Configuration ≠ remote object
Terraform detects drift

This guide uses a docker_container you can modify with docker update so every plan line is tied to a real out-of-band change. Work under ~/terraform-labs/terraform-drift-refresh-only/.

NOTE
Use the Terraform lab environment on Ubuntu with Docker Engine running. If Docker Hub rate-limits nginx:alpine pulls, tag the public mirror locally: docker pull public.ecr.aws/docker/library/nginx:alpine && docker tag public.ecr.aws/docker/library/nginx:alpine nginx:alpine.

What is Terraform drift?

Drift means the remote object changed while configuration and state still describe the previous picture. Terraform discovers drift when providers refresh current object data during plan or apply.

Three layers to keep separate:

Layer What it is Changes when you drift
Configuration .tf files in version control Only when you edit HCL
State terraform.tfstate mapping and last recorded attributes After apply, including -refresh-only apply
Remote object Real container, VM, bucket, etc. Anytime — including manual CLI changes

Changing configuration and changing infrastructure externally are different actions with different fixes. Editing main.tf drives the next normal plan toward new declared values. An out-of-band docker update leaves configuration untouched but changes what refresh reads from the API.


Create the Docker drift lab

Create the lab directory:

bash
mkdir -p ~/terraform-labs/terraform-drift-refresh-only && cd ~/terraform-labs/terraform-drift-refresh-only

Write a root module that manages one container with restart = "no" and a single label:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }
}

provider "docker" {}

resource "docker_image" "nginx" {
  name = "nginx:alpine"
}

resource "docker_container" "lab" {
  name    = "tf-drift-lab"
  image   = docker_image.nginx.image_id
  restart = "no"

  labels {
    label = "environment"
    value = "dev"
  }
}
EOF

Initialize providers and the Docker backend:

bash
terraform init -input=false -no-color

Apply the configuration so Terraform creates the container:

bash
terraform apply -auto-approve -input=false -no-color

The apply ends with Apply complete! and a running tf-drift-lab container.

Confirm the container exists outside Terraform:

bash
docker ps --filter name=tf-drift-lab --format '{{.Names}} {{.Status}}'

Sample output:

output
tf-drift-lab Up About a minute

Introduce an out-of-band change

Change the restart policy with the Docker CLI — configuration still says restart = "no":

bash
docker update --restart=unless-stopped tf-drift-lab

Docker accepts the update with no output beyond the container name when it succeeds:

output
tf-drift-lab

The live container now restarts on failure; Terraform configuration still declares restart = "no". That mismatch is drift.


Detect drift with terraform plan

A normal terraform plan refreshes remote object data, compares it to configuration and state, and proposes actions that would reconcile remote objects toward configuration:

bash
terraform plan -input=false -no-color

Sample output:

output
docker_container.lab: Refreshing state... [id=68abf13299d9ddb21285d4a42bace644505aed6aa6d6cf9d25a7adc2f9938e46]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

  # docker_container.lab will be updated in-place
  ~ resource "docker_container" "lab" {
        name                                        = "tf-drift-lab"
      ~ restart                                     = "unless-stopped" -> "no"
        # (49 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

The Refreshing state line is the provider reporting current reality. The ~ update proposes setting restart back to "no" on the remote container — undoing your docker update. A normal terraform apply would enforce configuration; refresh-only mode handles the case where you want state to accept the external value instead.


Use terraform plan -refresh-only

-refresh-only limits the plan to updates Terraform would make to state and root module outputs so they reflect refreshed remote data. It does not propose changes that modify remote objects to match configuration:

bash
terraform plan -refresh-only -input=false -no-color

Sample output:

output
Note: Objects have changed outside of Terraform

  # docker_container.lab has changed
  ~ resource "docker_container" "lab" {
        name                                        = "tf-drift-lab"
      ~ restart                                     = "no" -> "unless-stopped"
        # (41 unchanged attributes hidden)
    }

This is a refresh-only plan, so Terraform will not take any actions to undo
these. If you were expecting these changes then you can apply this plan to
record the updated values in the Terraform state without changing any remote
objects.

Read the direction carefully. A normal plan showed unless-stopped -> "no" (revert remote to config). Refresh-only shows "no" -> "unless-stopped" in state terms — record what refresh observed without touching the container.


Update state with terraform apply -refresh-only

terraform apply with -refresh-only approves the refresh-only plan and writes observed values into state without changing remote objects:

bash
terraform apply -refresh-only -auto-approve -input=false -no-color

Sample output:

output
Note: Objects have changed outside of Terraform

  # docker_container.lab has changed
  ~ resource "docker_container" "lab" {
      ~ restart                                     = "no" -> "unless-stopped"
    }

This is a refresh-only plan, so Terraform will not take any actions to undo
these.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

0 changed on resources means no remote API updates — only state moved. Inspect what state now records:

bash
terraform show -no-color | grep -A2 'restart'

Sample output:

output
restart                                     = "unless-stopped"

Run a normal plan again to see what still differs:

bash
terraform plan -input=false -no-color

Sample output:

output
# docker_container.lab will be updated in-place
  ~ resource "docker_container" "lab" {
      ~ restart                                     = "unless-stopped" -> "no"
    }

Plan: 0 to add, 1 to change, 0 to destroy.

After refresh-only:

  • Remote container — still unless-stopped (unchanged by refresh-only apply)
  • State — now records unless-stopped
  • Configuration — still restart = "no"

A normal plan still wants an in-place update because configuration and remote object disagree. Refresh-only accepted the external change into state; it did not edit main.tf or change the container. To align infrastructure with configuration, run a normal apply (or update configuration to match the new policy).


terraform refresh is deprecated

Older workflows ran:

bash
terraform refresh

That command refreshed state immediately without a review step. HashiCorp deprecated terraform refresh in favor of:

bash
terraform plan -refresh-only
terraform apply -refresh-only

The replacement workflow shows exactly which state fields would change before you commit them. Unreviewed refresh can hide surprising state updates — especially when credentials fail partway through refresh or when multiple attributes drift at once.

Do not teach terraform refresh as the preferred path. On Terraform 1.15.8 it may still run, but plan and apply -refresh-only are the supported review-first workflow.


Drift detection vs ignore_changes

These solve different problems:

Mechanism What it does Typical use
Normal plan / apply Reconcile remote objects toward configuration You want infrastructure to match .tf
plan / apply -refresh-only Update state to match refreshed remote data You accept an external change and want state to record it first
lifecycle { ignore_changes = [...] } Skip planning updates for listed attributes on future runs Another system owns those fields permanently

ignore_changes is declared in configuration — see Terraform lifecycle for ignore_changes depth. Refresh-only is a one-shot operational workflow when drift already happened and you want state to catch up without applying corrective changes to the remote object.


External deletion and other drift scenarios

Resource deleted outside Terraform

Remove the container manually while state still references it:

bash
docker rm -f tf-drift-lab

Run plan:

bash
terraform plan -input=false -no-color

Sample output:

output
# docker_container.lab will be created
  + resource "docker_container" "lab" {
      + name    = "tf-drift-lab"
      + restart = "no"
      + labels {
          + label = "environment"
          + value = "dev"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Terraform refresh sees the object gone and plans to recreate it on normal apply. Refresh-only records the deletion in state without creating the container until you choose a normal apply.

Common mistakes

Scenario What goes wrong Better approach
Manual console or CLI change Normal plan proposes revert Decide: normal apply to enforce config, or refresh-only to accept into state
External automation changes tags or policies Repeated drift noise every plan ignore_changes if Terraform should never manage those fields
Resource deleted outside Terraform Plan shows + create Normal apply recreates; refresh-only updates state first if you need a reviewed step
API or credential errors during refresh Plan may be based on stale data Fix provider access before trusting any plan
Assuming refresh-only edits .tf Configuration unchanged Edit HCL separately if policy should change
Using refresh-only when you want enforcement Remote stays drifted relative to config Use normal terraform apply
text
Want infrastructure to match .tf?
→ review normal terraform plan / apply

Want state to accept current external change?
→ review refresh-only workflow

Want Terraform to intentionally ignore selected changes?
→ lifecycle ignore_changes

Clean up the lab

Recreate the container if you removed it during the deletion demo:

bash
terraform apply -auto-approve -input=false -no-color

Tear down every managed resource when you finish the lab:

bash
terraform destroy -auto-approve -input=false -no-color

Destroy removes the container and clears lab state from your working directory.


References


Summary

You walked through real drift on a Docker container: docker update --restart=unless-stopped changed the remote object while main.tf still declared restart = "no". A normal terraform plan refreshed state data and proposed an in-place update to push the container back toward configuration.

terraform plan -refresh-only flipped the perspective — it showed how state would change to record unless-stopped without modifying the container. terraform apply -refresh-only committed that state update with 0 changed on resources, leaving configuration untouched and a normal plan still asking to revert restart policy if you want strict config enforcement.

terraform refresh is deprecated; use the refresh-only plan and apply pair when you need a reviewed state update. For attributes Terraform should never reconcile, ignore_changes in Terraform lifecycle is the long-term configuration answer — refresh-only is the operational tool when drift already happened and you need state to acknowledge it first.


Frequently Asked Questions

1. What is Terraform drift?

Drift occurs when a managed remote object changes outside Terraform so the real object no longer matches Terraform's recorded state. A normal plan refreshes remote data first, then compares the refreshed result with configuration to decide whether infrastructure changes are needed.

2. What does terraform plan -refresh-only do?

It builds a plan that only updates Terraform state and root module outputs to match refreshed remote object data. It does not propose changes that would modify remote objects to match configuration.

3. Does apply -refresh-only change my infrastructure?

No. A refresh-only apply writes updated attribute values into state after you approve the plan. Remote objects stay as they are; configuration files are not edited.

4. Is terraform refresh still recommended?

No. HashiCorp deprecated terraform refresh in favor of reviewing terraform plan -refresh-only and then running terraform apply -refresh-only when you want state to record external changes without changing remote objects.

5. What is the difference between refresh-only and ignore_changes?

Refresh-only updates state to acknowledge observed remote values for a one-time reconciliation review. ignore_changes tells Terraform to stop planning updates for selected attributes on future runs because those fields are intentionally managed outside Terraform.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)