| 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 plan workflow — execution plan symbols, create/update/replace/destroy previews, saved plans, -var and -var-file, -destroy, -refresh-only, -detailed-exitcode, -target, -replace, and how plan differs from apply. Does not cover apply/destroy depth, variable precedence, state manipulation, or provider setup. |
| Related guides | Terraform lab environment on Ubuntu Terraform init command terraform fmt command Terraform providers Terraform Associate certification course |
terraform plan is the preview step in the core workflow. It compares what you declared in configuration with what Terraform already tracks in state — and, during a normal plan, refreshes current remote objects through the provider — then prints an execution plan before anything changes. This guide uses the Docker provider on Ubuntu so you can verify every symbol with docker ps and docker inspect, not just read about them.
~/terraform-labs/terraform-plan/ so you do not collide with other lessons on the same VM. Run terraform init before your first plan in a new directory.
What does terraform plan do?
terraform plan answers one question: what would Terraform do if you applied this configuration right now?
Terraform builds that answer from three inputs:
configuration
+
state
+
provider refresh of current remote objects (unless refresh is disabled)
↓
execution planConfiguration is what you wrote in .tf files. State records which real-world objects Terraform already manages and their last known attributes.
During a normal plan, Terraform also asks providers to read current remote objects so the plan reflects reality — not only the last apply snapshot.
- State is the coordination layer Terraform uses to map configuration to real infrastructure
- It is not a perfect live inventory on its own — refresh during plan closes much of that gap
- The execution plan is still a preview until you run
terraform apply
Understand Terraform plan output
Every plan opens with a legend for resource actions, then ends with a summary line. After you run terraform plan on a new configuration, expect output shaped like this:
terraform planSample output (legend and summary):
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
~ update in-place
- destroy
-/+ destroy and then create replacement
...
Plan: 2 to add, 0 to change, 0 to destroy.| Symbol | Meaning |
|---|---|
+ |
Create a new remote object |
~ |
Update an existing object in place |
- |
Destroy an existing object |
-/+ |
Replace — destroy the old object, then create a new one |
The summary line counts proposed actions — for example Plan: 2 to add, 0 to change, 0 to destroy. Read the per-resource blocks above it for the exact attribute diffs.
Plan infrastructure changes
The sections below use one Docker container lab. You will run terraform apply between scenarios so each plan reflects a real change you can verify outside Terraform.
Lab setup
Create the lab directory:
mkdir -p ~/terraform-labs/terraform-planMove into it — every command below assumes you are here:
cd ~/terraform-labs/terraform-planWrite the provider requirements and container configuration. This lab uses a cached ghcr.io/nginx/nginx-unprivileged:alpine image so you are not blocked when Docker Hub rate-limits anonymous pulls:
terraform {
required_version = ">= 1.12.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
provider "docker" {}
variable "environment" {
type = string
default = "dev"
}
resource "docker_image" "nginx" {
name = "ghcr.io/nginx/nginx-unprivileged:alpine"
}
resource "docker_container" "web" {
name = "tf-plan-web"
image = docker_image.nginx.image_id
restart = "unless-stopped"
labels {
label = "environment"
value = var.environment
}
ports {
internal = 8080
external = 8081
}
}Save that as main.tf, then initialize the working directory:
terraform initSample output:
Terraform has been successfully initialized!Plan a new resource
With no managed resources recorded in state, the first plan proposes creates for both the image and the container:
terraform planSample output (trimmed):
Terraform will perform the following actions:
# docker_container.web will be created
+ resource "docker_container" "web" {
+ name = "tf-plan-web"
...
}
# docker_image.nginx will be created
+ resource "docker_image" "nginx" {
+ name = "ghcr.io/nginx/nginx-unprivileged:alpine"
...
}
Plan: 2 to add, 0 to change, 0 to destroy.The + symbols mean Terraform will create both resources on apply. Apply the plan so later scenarios have real infrastructure to compare:
terraform applyType yes when prompted, or pass -auto-approve in the lab. After apply, confirm the container exists:
docker ps --filter name=tf-plan-webNo changes
When configuration, state, and refreshed remote data all agree, Terraform reports a clean plan:
terraform planSample output:
No changes. Your infrastructure matches the configuration.
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.No symbols appear because there is nothing to do. This is the plan output you want in CI when -detailed-exitcode should return 0.
Plan an update
Change a mutable argument that the Docker provider can update in place. Edit main.tf and set restart = "no" while the applied container still has unless-stopped:
restart = "no"Run plan again:
terraform planSample output (trimmed):
Terraform will perform the following actions:
# docker_container.web will be updated in-place
~ resource "docker_container" "web" {
id = "2b65561557e0..."
name = "tf-plan-web"
~ restart = "unless-stopped" -> "no"
# (49 unchanged attributes hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.The ~ on restart is the signal you are looking for — an in-place update, not a replacement. Apply when you are ready to keep the lab aligned:
terraform applyRestore restart = "unless-stopped" in main.tf and apply again before the replacement demo.
Plan a resource replacement
Some arguments force replacement because providers cannot change them in place. On docker_container, changing name replaces the container:
name = "tf-plan-web-v2"Run plan:
terraform planSample output (trimmed):
# docker_container.web must be replaced
-/+ resource "docker_container" "web" {
~ name = "tf-plan-web" -> "tf-plan-web-v2"
...
}
Plan: 1 to add, 0 to change, 1 to destroy.The -/+ symbol means destroy-then-create. The summary counts that as one add and one destroy. Revert name to tf-plan-web in main.tf without applying the replacement plan.
Plan a resource destruction
Remove a managed resource from configuration and Terraform plans to destroy the orphaned remote object. Delete the entire docker_container.web block from main.tf while leaving docker_image.nginx in place:
terraform planSample output (trimmed):
# docker_container.web will be destroyed
- resource "docker_container" "web" {
- name = "tf-plan-web" -> null
...
}
Plan: 0 to add, 0 to change, 1 to destroy.Terraform proposes deletion because the container exists in state but no longer appears in configuration. Restore the docker_container.web block before continuing — the later flag demos need the full configuration.
Save and inspect a Terraform plan
A speculative terraform plan on stdout is useful for humans. Automation and review gates often need a saved plan file instead.
Save a plan with -out
Make a pending change — set restart = "no" while the live container still uses unless-stopped — then write the plan to disk:
terraform plan -out=tfplanSample output:
Plan: 0 to add, 1 to change, 0 to destroy.The tfplan file holds the exact actions Terraform captured at plan time. That is a saved plan, not a speculative stdout review. Someone can inspect it with terraform show and apply it later with terraform apply tfplan so the apply step matches the approved plan.
sensitive in configuration and other data from state. Do not commit them to version control or publish them in ticket systems without reviewing contents.
Inspect with terraform show
Read the saved plan without applying it:
terraform show tfplanSample output (trimmed):
# docker_container.web will be updated in-place
~ resource "docker_container" "web" {
~ restart = "unless-stopped" -> "no"
}
Plan: 0 to add, 1 to change, 0 to destroy.terraform show against a saved plan replays the same execution plan that was frozen at -out time.
Inspect plan as JSON
Machine-readable review uses JSON:
terraform show -json tfplanPipe through jq to list addresses and actions — not to build a full Terraform JSON API tutorial:
terraform show -json tfplan | jq '.resource_changes[] | {address: .address, actions: .change.actions}'Sample output:
{
"address": "docker_container.web",
"actions": [
"update"
]
}terraform show -json can expose sensitive values in plain text even when Terraform hides them in normal plan output. Redirect JSON to a protected location and scrub logs before sharing.
The saved plan has served its purpose — remove it before you change configuration for the next examples. Applying tfplan later would still execute the frozen restart = "no" change, not regenerate a plan from whatever main.tf says afterward:
rm -f tfplanRestore restart = "unless-stopped" in main.tf after deleting the saved plan file.
terraform plan options
These flags adjust what the plan compares or how the command exits. Variable precedence belongs in a dedicated Terraform variables lesson — here you only need to see that plan accepts the same -var inputs as apply.
Pass variables with -var and -var-file
Override the environment label at plan time:
terraform plan -var='environment=qa'Sample output (trimmed):
~ labels {
- value = "dev" -> null
+ value = "qa"
}
Plan: 1 to add, 0 to change, 1 to destroy.Create a variable file for repeatable overrides:
printf 'environment = "staging"\n' > dev.tfvarsPass the file to plan:
terraform plan -var-file=dev.tfvarsSample output (trimmed):
+ value = "staging"
Plan: 1 to add, 0 to change, 1 to destroy.Changing the label value replaces the container in this provider — the point here is how flags feed configuration into the plan, not the replacement mechanics.
Preview destroy with -destroy
-destroy plans deletion of every managed resource in the module even when configuration still declares them:
terraform plan -destroySample output (trimmed):
# docker_container.web will be destroyed
- resource "docker_container" "web" { ... }
# docker_image.nginx will be destroyed
- resource "docker_image" "nginx" { ... }
Plan: 0 to add, 0 to change, 2 to destroy.Use it when you want to preview a full teardown before deciding whether to run a destroy operation or save and apply a destroy plan.
Refresh-only plans
When someone changes infrastructure outside Terraform, a normal plan might propose corrective changes immediately. -refresh-only limits the plan to updating state to match refreshed remote data:
docker update --restart=no "$(docker ps -q -f name=tf-plan-web)"That command changes the live restart policy while main.tf still says unless-stopped. Now ask for a refresh-only plan:
terraform plan -refresh-onlySample output (trimmed):
Note: Objects have changed outside of Terraform
# docker_container.web has changed
~ resource "docker_container" "web" {
~ restart = "unless-stopped" -> "no"
}
This is a refresh-only plan, so Terraform will not take any actions to undo
these.To accept this drift into state without changing the container, run terraform apply -refresh-only. For this walkthrough, leave it unapplied and restore the Docker restart policy before continuing:
docker update --restart=unless-stopped "$(docker ps -q -f name=tf-plan-web)"Confirm configuration, state, and the live container agree again:
terraform planSample output:
No changes. Your infrastructure matches the configuration.Full drift workflows belong in the dedicated drift and refresh-only guide — here the flag shows how Terraform separates refresh state from propose infrastructure changes.
Exit codes with -detailed-exitcode
Scripts use -detailed-exitcode to distinguish “no drift” from “changes pending”:
terraform plan -detailed-exitcodeWhen the plan is clean:
No changes. Your infrastructure matches the configuration.Capture the exit status:
echo $?Sample output:
0Introduce a pending change (restart = "no" in main.tf) and rerun:
terraform plan -detailed-exitcodeSample output (trimmed):
Plan: 0 to add, 1 to change, 0 to destroy.Read the exit status Terraform returns when differences are present:
echo $?Sample output:
2| Exit code | Meaning |
|---|---|
0 |
Plan succeeded; no differences |
1 |
Error (configuration, provider, state lock, etc.) |
2 |
Plan succeeded; differences present |
Restore restart = "unless-stopped" in main.tf. Because the restart = "no" plan was never applied, the configuration again matches the running container — no apply is needed.
Target or replace specific resources
-target and -replace are exceptional controls. Use them only when Terraform or your runbook explicitly calls for targeted recovery or a forced recreation — not as everyday dependency management.
-target limits planning to one resource address and its dependencies:
terraform plan -target=docker_container.webTerraform prints a warning that targeted plans are exceptional:
The -target option is not for routine use, and is provided only for
exceptional situations such as recovering from errors or mistakes...-replace recreates a resource deliberately and is safer than manual state surgery:
terraform plan -replace=docker_container.webSample output (trimmed):
# docker_container.web will be replaced, as requested
-/+ resource "docker_container" "web" {
...
}The as requested wording tells you Terraform is replacing because of the flag, not because configuration alone forced it.
Disable refresh with -refresh=false
-refresh=false skips provider refresh during plan. Terraform then compares configuration against state records only, which can miss changes made outside Terraform since the last apply.
Use this only when you understand the trade-off — for example a tightly controlled pipeline step where refresh is handled separately. Default planning with refresh enabled is safer for most workflows.
terraform plan vs terraform apply
terraform plan |
terraform apply |
|
|---|---|---|
| Purpose | Preview proposed changes | Execute proposed changes |
| Modifies remote infrastructure | No — normal plan does not run create/update/destroy actions | Yes — when you approve or auto-approve |
| Updates state on success | No for speculative plans; refresh-only plans update state when applied | Yes |
| Saved plan workflow | terraform plan -out=tfplan |
terraform apply tfplan applies the saved plan |
| Typical next step | Review output or share tfplan |
Verify with docker ps, outputs, or tests |
Plan is the safety gate. Apply is where resources actually change.
Does terraform plan make changes?
A normal speculative terraform plan does not execute the +, ~, -, or -/+ actions it prints. It is a read-only preview of what apply would do.
Two nuances matter in current Terraform:
- Refresh during plan — by default, Terraform reads current remote objects while planning so the diff is accurate. That is provider read activity, not the create/update/destroy actions in the plan
- Saved and refresh-only plans —
terraform apply tfplanexecutes a saved plan.terraform applyon a refresh-only plan updates state without changing remote objects when that is all the plan contains
If you need proof, run docker ps before and after a speculative plan — container IDs stay the same until apply.
Common terraform plan errors
| Symptom | Likely cause | Fix |
|---|---|---|
terraform init required |
Working directory not initialized | Run terraform init in the module root |
Error: Value for undeclared variable |
-var names a variable that does not exist |
Declare the variable or fix the flag spelling |
Error: No value for required variable |
Required variable missing at plan time | Pass -var / -var-file or set a default |
| Provider authentication or connection error | Docker socket permissions or daemon stopped | Confirm docker ps works; see Terraform providers |
Error acquiring the state lock |
Another Terraform process holds the lock | Wait for the other run to finish or follow your team's lock policy |
Error: Cycle in dependency graph |
Circular resource references | Break the cycle in configuration |
| Provider API error during refresh | Remote object deleted outside Terraform | Import, remove from state, or fix configuration — depends on intent |
Plan before init
A fresh directory without provider plugins cannot plan:
terraform planSample output (trimmed):
Error: Inconsistent dependency lock file
...
run:
terraform initRun init once per working directory before planning.
References
- terraform plan command
- terraform show command
- terraform apply command
- Resource behavior — planned changes
- Docker provider documentation
Summary
terraform plan compares configuration, state, and refreshed provider data to build an execution plan you review before anything changes.
You walked through real Docker provider output for:
- Create, in-place update, replacement, and destroy proposals
- The quiet "no changes" path when everything already matches
Saved plans with -out freeze a plan for terraform show review or later terraform apply tfplan — treat those files as sensitive.
Flags such as -var, -destroy, -refresh-only, -detailed-exitcode, -replace, and exceptional -target adjust how the plan is built or how automation interprets the result.
The mistake to avoid is treating a successful plan as deployment:
- Plan previews
- Apply executes
When CI needs a boolean signal, -detailed-exitcode separates clean runs (0) from pending changes (2).
Next in the Core Workflow track: terraform apply, where the execution plan actually changes infrastructure.

