| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1kreuzwerker/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.
Terraform creates resource
↓
Someone changes resource outside Terraform
↓
Configuration ≠ remote object
↓
Terraform detects driftThis 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/.
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:
mkdir -p ~/terraform-labs/terraform-drift-refresh-only && cd ~/terraform-labs/terraform-drift-refresh-onlyWrite a root module that manages one container with restart = "no" and a single label:
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"
}
}
EOFInitialize providers and the Docker backend:
terraform init -input=false -no-colorApply the configuration so Terraform creates the container:
terraform apply -auto-approve -input=false -no-colorThe apply ends with Apply complete! and a running tf-drift-lab container.
Confirm the container exists outside Terraform:
docker ps --filter name=tf-drift-lab --format '{{.Names}} {{.Status}}'Sample output:
tf-drift-lab Up About a minuteIntroduce an out-of-band change
Change the restart policy with the Docker CLI — configuration still says restart = "no":
docker update --restart=unless-stopped tf-drift-labDocker accepts the update with no output beyond the container name when it succeeds:
tf-drift-labThe 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:
terraform plan -input=false -no-colorSample 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:
terraform plan -refresh-only -input=false -no-colorSample 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:
terraform apply -refresh-only -auto-approve -input=false -no-colorSample 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:
terraform show -no-color | grep -A2 'restart'Sample output:
restart = "unless-stopped"Run a normal plan again to see what still differs:
terraform plan -input=false -no-colorSample 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:
terraform refreshThat command refreshed state immediately without a review step. HashiCorp deprecated terraform refresh in favor of:
terraform plan -refresh-only
terraform apply -refresh-onlyThe 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:
docker rm -f tf-drift-labRun plan:
terraform plan -input=false -no-colorSample 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 |
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_changesClean up the lab
Recreate the container if you removed it during the deletion demo:
terraform apply -auto-approve -input=false -no-colorTear down every managed resource when you finish the lab:
terraform destroy -auto-approve -input=false -no-colorDestroy removes the container and clears lab state from your working directory.
References
- terraform refresh command — Terraform CLI documentation
- terraform plan command — Terraform CLI documentation
- terraform apply command — Terraform CLI documentation
- The terraform refresh command is deprecated — HashiCorp changelog
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.

