Changing Infrastructure Safely
Reading a plan before you apply it: create, update and replace, drift and refresh, importing existing resources, moved blocks, lifecycle rules and preventing accidental destruction.
Reading a plan
The init, plan, apply workflow puts one step between your edit and the real world: the plan. A plan is a dry run that lists every action the tool intends to take. It is only useful if someone reads it. Every action has a symbol:
| Symbol | Plan text | What happens |
|---|---|---|
+ |
will be created |
A new object |
~ |
will be updated in-place |
The same object, with changed settings |
-/+ |
must be replaced |
The old object is destroyed, then a new one is created |
+/- |
must be replaced |
The new object is created first, then the old one is
destroyed (create_before_destroy)
|
- |
will be destroyed |
The object is deleted |
<= |
will be read during apply |
A data source is read during apply |
. |
will be removed from the OpenTofu state but will not be
destroyed
|
The object is forgotten, not deleted (OpenTofu wording) |
| (none) |
has moved to, will be imported
|
Only the state changes |
The plan ends with a summary line, such as
Plan: 1 to add, 1 to change, 1 to destroy. When imports
or forgotten objects are involved it grows to
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
or …, 0 to destroy, 1 to forget. Start every review
there. At Kestrel Energy, Yara Haddad, the release manager, approves
production applies in the pipeline (the deployment approval); the
change
record names the change authority that fits the risk. The summary
line goes into the change record, and any "to destroy" she did not
expect stops the change until someone explains it.
Update in place or replace?
Some settings can be changed on a live object: a tag, a firewall rule's description, the size of many cloud volumes. Others cannot: the name of a database, the image a virtual machine was built from, the subnet of a network interface. For those, the provider tells the tool that the only way to the new value is a resource replacement: destroy the existing object and create a new one with the new settings. A replacement loses whatever lived on the old object — data on a disk, a public IP address, the database itself — unless you planned for it.
Ravi Kumar's portal configuration (recorded with local stand-ins:
terraform_data for a deployment record,
local_file for a rendered nginx configuration) shows
both. Bumping the release only updates the deployment record:
variable "release" {
description = "Version of the portal application to deploy."
type = string
default = "2026.10.1"
}
resource "terraform_data" "release" {
input = {
version = var.release
environment = var.environment
}
}
$ tofu plan -var release=2026.10.2
…
OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
~ update in-place (current -> planned)
OpenTofu will perform the following actions:
# terraform_data.release will be updated in-place
~ resource "terraform_data" "release" {
id = "e750094c-58ff-cf63-01f9-a5eb48342a0e"
~ input = {
~ version = "2026.10.1" -> "2026.10.2"
# (1 unchanged attribute hidden)
}
~ output = {
- environment = "dev"
- version = "2026.10.1"
} -> (known after apply)
}
Plan: 0 to add, 1 to change, 0 to destroy.
…
Changing the environment from dev to
test also changes the content of the generated file.
The local provider cannot edit a file in place, so the plan replaces
it:
$ tofu plan -var environment=test
…
# local_file.upstream must be replaced
-/+ resource "local_file" "upstream" {
~ content = <<-EOT # forces replacement
- # Kestrel Energy customer portal (dev), deployment 0a80e8ff
+ # Kestrel Energy customer portal (test), deployment 0a80e8ff
upstream kestrel_portal {
server 192.0.2.11:8080;
server 192.0.2.12:8080;
}
EOT
…
~ id = "577cb8ce792092da6359d85e27648b4f11d5a85a" -> (known after apply)
# (3 unchanged attributes hidden)
}
# terraform_data.release will be updated in-place
…
Plan: 1 to add, 1 to change, 1 to destroy.
Two things to learn from this output. First,
# forces replacement marks the attribute responsible;
look for it whenever you see -/+. Second, one
replacement counts as both "1 to add" and "1 to destroy", so "1 to
add, 1 to change, 1 to destroy" here means two resources change, not
three.
You can also ask for a replacement on purpose, for example to get a
fresh server or regenerate a value. -replace=ADDRESS
replaces that object even though nothing changed; resources that use
its values are updated or, as the file here, replaced too:
$ tofu plan -replace=random_id.deployment -var environment=test
…
# random_id.deployment will be replaced, as requested
-/+ resource "random_id" "deployment" {
…
Plan: 2 to add, 0 to change, 2 to destroy.
Changes to Outputs:
~ deployment_id = "0a80e8ff" -> (known after apply)
…
Saved plans
A plain tofu apply makes a new plan and asks for
yes. Between the plan you reviewed this morning and
that new plan, the code, the state or the real world may have
changed. The OpenTofu output says so after every unsaved plan:
Note: You didn't use the -out option to save this plan, so OpenTofu can't guarantee to take exactly these actions if you run "tofu apply" now.
A
saved plan file is a plan
written to disk with -out. Applying that file carries
out exactly those actions and nothing else, and it is refused if the
state has changed since the plan was made:
$ tofu plan -out=portal.tfplan -var environment=test -var release=2026.10.2
$ tofu apply portal.tfplan
$ tofu apply portal.tfplan
…
Plan: 0 to add, 1 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Saved the plan to: portal.tfplan
To perform exactly these actions, run the following command to apply:
tofu apply "portal.tfplan"
terraform_data.release: Modifying... [id=e750094c-58ff-cf63-01f9-a5eb48342a0e]
terraform_data.release: Modifications complete after 0s [id=e750094c-58ff-cf63-01f9-a5eb48342a0e]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
…
╷
│ Error: Saved plan is stale
│
│ The given plan file can no longer be applied because the state was changed
│ by another operation after the plan was created.
╵
The second apply of the same file fails: the first one changed the
state, so the plan no longer describes the starting point. That
refusal is a safety feature, not a bug.
tofu show portal.tfplan prints a saved plan for review;
in OpenTofu 1.13 the file includes the provider schemas, so showing
it usually does not need to start the providers.
This is the basis of the pipeline pattern on infrastructure as code in pipelines: plan once on the pull request, review, then apply that plan file. Infrastructure as Code in Pipelines goes into the details. A plan file contains the same secrets as the state; keep it short-lived and protected, or encrypt it with OpenTofu's plan encryption (see state encryption).
Drift and refresh-only plans
Configuration drift is any change made outside the code. During an incident at Kestrel, Tomasz Nowak from the network operations center adds a third portal server to the nginx upstream by hand. The next plan notices:
$ echo ' server 192.0.2.13:8080; # added by hand' >> out/portal-upstream.conf
$ tofu plan -var environment=test
…
OpenTofu will perform the following actions:
# local_file.upstream will be created
+ resource "local_file" "upstream" {
…
Plan: 1 to add, 0 to change, 0 to destroy.
A normal plan does two things at once: it refreshes the state from
reality, and it proposes to make reality match the code again. The
local provider treats a file whose content no longer matches as
deleted, so the plan proposes to create it again; a cloud provider
would usually show the changed attribute with ~
instead. Applying would silently remove Tomasz's emergency fix.
A
refresh-only plan
(-refresh-only) does only the first half: it shows what
changed outside the tool and proposes to record that in the state,
without touching the real infrastructure.
$ tofu plan -refresh-only -var environment=test
…
Note: Objects have changed outside of OpenTofu
OpenTofu detected the following changes made outside of OpenTofu since the
last "tofu apply" which may have affected this plan:
# local_file.upstream has been deleted
- resource "local_file" "upstream" {
…
This is a refresh-only plan, so OpenTofu 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 OpenTofu state without changing any remote
objects.
…
For scheduled drift checks, -detailed-exitcode turns
the answer into an
exit status:
0 means no changes, 1 an error, 2 changes pending.
$ tofu plan -var environment=test -detailed-exitcode > /dev/null
In a test run this plan, run after the manual edit, exited with status 2.
When drift is found, someone has to decide which side is right. If
the manual change was a mistake, apply the code and the drift is
gone. If it was a necessary
emergency change, put the same change in the code — here, add
192.0.2.13:8080 to the server list — so the next plan
shows no changes. Only use apply -refresh-only to
accept changes into the state when the code already describes them,
or for attributes the code does not manage. (The older
refresh command does the same without showing you
anything first; prefer the refresh-only plan.)
Renaming with a moved block
The tool identifies an object by its address:
local_file.billing. Rename the block to
billing_export and, as far as the tool can tell, the
old resource was deleted from the code and a new one added. The plan
follows that logic:
$ tofu plan | grep -E "#|Plan:"
# local_file.billing will be destroyed # (because local_file.billing is not in configuration) # local_file.billing_export will be created Plan: 1 to add, 0 to change, 1 to destroy.
For a file that is harmless; for a database it is a disaster. A moved block records in code that an object has a new address, so the tool updates the state instead of destroying anything:
moved.tf
moved {
from = local_file.billing
to = local_file.billing_export
}
$ tofu plan
local_file.billing_export: Refreshing state... [id=a978606858cf24e02283cd7e068a6630617d5bcd]
local_file.portal_nginx: Refreshing state... [id=507bb80fbbb05c9a9e77e117546aad21ecde8932]
OpenTofu will perform the following actions:
# local_file.billing has moved to local_file.billing_export
resource "local_file" "billing_export" {
id = "a978606858cf24e02283cd7e068a6630617d5bcd"
# (10 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
…
The same works for moving a resource into a module (to = module.billing.local_file.this), for switching from count to
for_each (to = local_file.web["web01"])
and for renaming a module call. Keep moved blocks in
the code for a while — every copy of the configuration (other
environments, colleagues' branches) needs to apply them once — and
remove them later.
The older way is tofu state mv FROM TO, which changes
the state immediately:
$ tofu state mv local_file.nginx local_file.portal_nginx
Move "local_file.nginx" to "local_file.portal_nginx" Successfully moved 1 object(s).
It works, but nobody reviews it, other environments do not get it,
and if the code is not renamed at the same moment the next plan
wants to destroy and re-create. Prefer the moved block,
which travels through a pull request like any other change.
Stopping management with a removed block
Sometimes an object should stay, but no longer be managed by this
configuration: it is handed over to another team's code, or it will
be run by hand from now on. Deleting the resource block would
destroy it. A
removed block says instead:
forget this object, and (with destroy = false) leave it
running. It exists since Terraform 1.7 and OpenTofu 1.7. Terraform
requires the lifecycle { destroy = false } shown here.
OpenTofu accepts that lifecycle block only from 1.10;
in OpenTofu 1.7–1.9 a removed block contains just
from and always forgets without destroying.
removed.tf (the resource block itself is deleted)
removed {
from = local_file.portal_nginx
lifecycle {
destroy = false
}
}
$ tofu apply -auto-approve
$ tofu state list
$ ls out/
…
OpenTofu used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
. forget
OpenTofu will perform the following actions:
# local_file.portal_nginx will be removed from the OpenTofu state but will not be destroyed
. resource "local_file" "portal_nginx" {
…
}
Plan: 0 to add, 0 to change, 0 to destroy, 1 to forget.
╷
│ Warning: Resource will be removed from the state
│
│ After this plan is applied, the resource local_file.portal_nginx will not
│ be managed anymore by OpenTofu.
│
│ In case you want to manage the resource again, you will have to import it.
╵
Apply complete! Resources: 0 added, 0 changed, 0 destroyed, 1 forgotten.
local_file.billing_export
billing-export.cron
nginx-portal.conf
The state no longer lists the nginx file, but the file is still on
disk. tofu state rm does the same from the command
line, without review; the removed block is the
reviewable version.
Importing existing resources
The opposite situation is common in organizations that adopt
infrastructure as code gradually: the object exists — created by
hand years ago, or in the console during an incident — and should be
managed from now on. An
import block tells the tool
which existing object (by its ID in the real system) belongs to
which resource address. The import happens in a normal plan and
apply, so it can be reviewed. Import blocks exist since Terraform
1.5 and OpenTofu 1.6; the older import command does the
same immediately, without a plan.
Not every resource type can be imported; that is up to the provider. Ravi's first attempt, adopting the nginx file that was just forgotten, fails:
import {
to = local_file.portal_nginx
id = "./out/nginx-portal.conf"
}
$ tofu plan -generate-config-out=generated.tf
local_file.portal_nginx: Preparing import... [id=./out/nginx-portal.conf] local_file.billing_export: Refreshing state... [id=a978606858cf24e02283cd7e068a6630617d5bcd] Planning failed. OpenTofu encountered an error while generating this plan. ╷ │ Error: Resource Import Not Implemented │ │ This resource does not support import. Please contact the provider │ developer for additional information. ╵
A random_id can be imported. Joost Bakker's billing
export already uses deployment ID CoDo_w; instead of
generating a new one, he adopts it. With
-generate-config-out, OpenTofu also writes a starting
resource block for him:
import.tf
# The billing export already uses this deployment ID; adopt it instead of generating a new one.
import {
to = random_id.billing_export
id = "CoDo_w"
}
$ tofu plan -generate-config-out=generated.tf
$ cat generated.tf
random_id.billing_export: Preparing import... [id=CoDo_w]
random_id.billing_export: Refreshing state... [id=CoDo_w]
OpenTofu will perform the following actions:
# random_id.billing_export will be imported
# (config will be generated)
# (imported from "CoDo_w")
resource "random_id" "billing_export" {
b64_std = "CoDo/w=="
b64_url = "CoDo_w"
byte_length = 4
dec = "176220415"
hex = "0a80e8ff"
id = "CoDo_w"
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
╷
│ Warning: Config generation is experimental
│
│ Generating configuration during import is currently experimental, and the
│ generated configuration format may change in future versions.
╵
─────────────────────────────────────────────────────────────────────────────
OpenTofu has generated configuration and written it to generated.tf. Please
review the configuration and edit it as necessary before adding it to version
control.
…
# __generated__ by OpenTofu
# Please review these resources and move them into your main configuration files.
# __generated__ by OpenTofu from "CoDo_w"
resource "random_id" "billing_export" {
byte_length = 4
keepers = null
prefix = null
}
$ tofu apply -auto-approve
… Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. random_id.billing_export: Importing... [id=CoDo_w] random_id.billing_export: Import complete [id=CoDo_w] Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.
Treat generated configuration as a draft: it lists optional
arguments as null and copies whatever the object
happens to have, including settings you may want to change. Clean it
up, move it into the right file, and run a plan until it says "No
changes" for that resource. OpenTofu writes generated configuration
only into a new file; it refuses to overwrite an existing
generated.tf. Once the import is applied, the
import block can be removed.
Lifecycle rules
A lifecycle block inside a resource changes how the tool creates, updates, replaces and destroys that resource. The arguments you will use:
-
create_before_destroy = true— on replacement, create the new object first and destroy the old one afterward (+/-instead of-/+). Useful for anything that must not be absent for a moment, such as a certificate or a load-balanced server. The new object must be able to exist next to the old one, so names usually need a unique part. -
ignore_changes = [attribute, …]— differences in these attributes are ignored after creation. Useful when another system legitimately changes them (an autoscaler setting the instance count, tags added by a cost tool). Never useignore_changes = allas a way to silence a plan. -
replace_triggered_by = [reference, …]— replace this resource whenever another resource or attribute changes. -
preconditionandpostconditionblocks — checks with your own error message, evaluated during plan and apply. prevent_destroy = true— see below.
In the meter-api example the file name includes the release, so the new file can be created before the old one is removed:
variable "release" {
type = string
default = "2026.10.1"
}
resource "local_file" "meter_api" {
filename = "${path.module}/out/meter-api-${var.release}.env"
content = "METER_API_RELEASE=${var.release}\n"
lifecycle {
create_before_destroy = true
}
}
$ tofu apply -auto-approve -var release=2026.10.2
…
+/- create replacement and then destroy
OpenTofu will perform the following actions:
# local_file.meter_api must be replaced
+/- resource "local_file" "meter_api" {
…
~ filename = "./out/meter-api-2026.10.1.env" -> "./out/meter-api-2026.10.2.env" # forces replacement
…
Plan: 1 to add, 0 to change, 1 to destroy.
local_file.meter_api: Creating...
local_file.meter_api: Creation complete after 0s [id=414c0880dfcf3c05c5ae82ba8d755a9d52c769ab]
local_file.meter_api (deposed object b2a9997f): Destroying... [id=9ad167fe3e10942bf57718c6b01b8afeb44379b3]
local_file.meter_api: Destruction complete after 0s
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
During the apply, the old object is called the deposed object: it still exists, but the address already belongs to the new one.
prevent_destroy
prevent_destroy is a
lifecycle argument that makes any plan fail if it would destroy that
resource — including a replacement and a full destroy.
Hamid Rahimi wants it on everything that holds data on db01. In a
test run it protects the generated database password, and
ignore_changes keeps the release record stable:
resource "random_password" "db" {
length = 20
special = false
lifecycle {
prevent_destroy = true
}
}
resource "terraform_data" "release" {
input = {
version = var.release
environment = var.environment
}
lifecycle {
ignore_changes = [input]
}
}
$ tofu plan -var environment=test -var release=2026.10.3
$ tofu destroy -auto-approve -var environment=test
…
No changes. Your infrastructure matches the configuration.
…
OpenTofu planned the following actions, but then encountered a problem:
…
Plan: 0 to add, 0 to change, 4 to destroy.
…
╷
│ Error: Resource instance cannot be destroyed
│
│ on main.tf line 5:
│ 5: resource "random_password" "db" {
│
│ Resource instance random_password.db has prevent_destroy set, but the plan
│ calls for it to be destroyed.
│
│ To proceed, either disable prevent_destroy for this resource or exclude
│ instances of this resource from this round using:
│ -exclude="random_password.db"
╵
The release bump produced "No changes" because of
ignore_changes. The destroy stopped completely: not one
of the four resources was destroyed. The suggestion to use
-exclude is OpenTofu-specific (since 1.9); Terraform
has -target only.
OpenTofu 1.12 and Terraform 1.16 added
destroy = false to the resource
lifecycle block as well: the object is then only
removed from the state, never destroyed, when the tool would
otherwise delete it. It is newer than the other rules; check that
every tool version your team uses supports it before you rely on it.
A safe change, step by step
- Keep it small. One purpose per pull request limits the blast radius and keeps the plan readable.
-
Plan, save, and read. Summary line first, then
every
-/+,-and# forces replacement. Unexpected lines are questions, not noise. -
Use the declarative blocks —
moved,removed,import— instead of state commands, so the change is in the review. - Second pair of eyes. A colleague reviews the code and the plan (the four-eyes principle); for production, the change authority approves.
-
Apply the reviewed plan file, ideally from a
pipeline, never a fresh plan with
-auto-approvefrom a laptop. - Know the way back. For infrastructure the back-out plan is usually "revert the commit and apply again" — but a destroyed database does not come back that way, so check backups before changes that replace stateful resources.
-target and -exclude
-target=ADDRESS limits a plan to one resource and what
it depends on; OpenTofu also has the opposite,
-exclude=ADDRESS, plus -target-file and
-exclude-file (OpenTofu 1.10). The documentation says
to use them in exceptional circumstances only: recovering from a
mistake, or working around a provider bug. A partial apply leaves
the rest of the configuration unapplied, so the code and reality
drift apart until the next full plan. If you find yourself using
-target every week, the configuration is too big —
split it.