| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1hashicorp/local 2.9.0kreuzwerker/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 |
| Scope | Removing Terraform management without destroying real infrastructure — default destroy when a resource block disappears, removed block with lifecycle destroy false, terraform state rm, independent verification outside Terraform, behavior when configuration still declares the resource, removed versus state rm comparison, Docker import handoff to another root module, and common safety mistakes. Does not cover manual tfstate JSON editing, full state disaster recovery, or deleting state files as a fix. |
| Related guides | Terraform moved and removed blocks terraform state commands Terraform import Terraform state explained Terraform troubleshooting |
Deleting a resource block and running terraform apply tells Terraform to destroy whatever that address tracked. That is correct when you want the provider to delete a file, volume, or cloud object. It is wrong when the real infrastructure should survive and only Terraform should stop managing it.
Delete block + apply → provider destroy (default)
removed { destroy=false } → forget in state, object stays
terraform state rm → forget in state immediately, object staysEach scenario uses its own directory under ~/terraform-labs/terraform-remove-resource-without-destroy/. The lab verifies files with cat and sha256sum, and a Docker volume with docker volume inspect — not only Terraform output.
terraform.tfstate or remote state objects to “forget” one resource. That removes tracking for the entire workspace and does not call provider APIs safely. Use removed, terraform state rm on a single address, or a documented import and migration path instead.
Why removing the resource block plans a destroy
Terraform reconciles configuration with state. When an address exists in state but no longer appears in configuration, the default action is destroy — Terraform asks the provider to delete the real object.
mkdir -p ~/terraform-labs/terraform-remove-resource-without-destroy/demos/why-destroy/generated
cd ~/terraform-labs/terraform-remove-resource-without-destroy/demos/why-destroycat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "keep" {
filename = "${path.module}/generated/keep-me.txt"
content = "why-destroy-lab-distinctive-content-v1"
}
EOFInitialize and apply so the file exists on disk and in state:
terraform init -input=falseterraform apply -auto-approve -input=false -no-colorConfirm the bytes landed outside Terraform with cat and a checksum:
cat ~/terraform-labs/terraform-remove-resource-without-destroy/demos/why-destroy/generated/keep-me.txtwhy-destroy-lab-distinctive-content-v1sha256sum ~/terraform-labs/terraform-remove-resource-without-destroy/demos/why-destroy/generated/keep-me.txt97e1a131892d18b73b8f3771dc680715c89b3a49e6a76c06d8b190c693fc6900 .../keep-me.txtWith the resource block still present, plan reports no changes. Remove the resource "local_file" "keep" block — keep only the terraform block:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
EOFPlan again:
terraform plan -no-color -input=false# local_file.keep will be destroyed
# (because local_file.keep is not in configuration)
- resource "local_file" "keep" {
- filename = "./generated/keep-me.txt" -> null
...
}
Plan: 0 to add, 0 to change, 1 to destroy.Terraform plans to delete the file through the local provider because the address left configuration without a removed block or prior state rm. Do not apply this plan if you want the file to remain — use one of the methods below instead.
Method 1: removed block with destroy = false
Terraform 1.7+ supports a declarative removed block that records “stop managing this address” in configuration. Set lifecycle { destroy = false } so apply forgets the binding without calling the provider delete API.
mkdir -p ~/terraform-labs/terraform-remove-resource-without-destroy/demos/removed-block/generated
cd ~/terraform-labs/terraform-remove-resource-without-destroy/demos/removed-blockCreate the resource first:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "persist" {
filename = "${path.module}/generated/persist-removed.txt"
content = "removed-block-lab"
}
EOFInitialize and apply:
terraform init -input=falseterraform apply -auto-approve -input=false -no-colorReplace the resource block with a removed block:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
removed {
from = local_file.persist
lifecycle {
destroy = false
}
}
EOFPlan shows forget-without-destroy — not a provider delete:
terraform plan -no-color -input=false# local_file.persist will no longer be managed by Terraform, but will not be destroyed
# (destroy = false is set in the configuration)
Plan: 0 to add, 0 to change, 0 to destroy.
Warning: Some objects will no longer be managed by Terraform
- local_file.persistApply drops the address from state:
terraform apply -auto-approve -input=false -no-colorAfter apply, terraform state list is empty for this demo — Terraform no longer tracks local_file.persist.
Method 2: terraform state rm
terraform state rm ADDRESS removes one resource instance from state immediately. It does not run a plan and does not call the provider destroy API.
mkdir -p ~/terraform-labs/terraform-remove-resource-without-destroy/demos/state-rm/generated
cd ~/terraform-labs/terraform-remove-resource-without-destroy/demos/state-rmcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "persist" {
filename = "${path.module}/generated/persist-rm.txt"
content = "state-rm-lab"
}
EOFInitialize and apply:
terraform init -input=falseterraform apply -auto-approve -input=false -no-colorForget the address while leaving the resource block in configuration:
terraform state rm local_file.persistRemoved local_file.persist
Successfully removed 1 resource instance(s).terraform state list prints nothing — the binding is gone. The HCL block is still present, which matters for the next plan.
Verify the real resource still exists
Terraform saying “removed from state” is not enough for production handoffs. Verify the object independently.
After the removed block apply, read the file directly:
cat ~/terraform-labs/terraform-remove-resource-without-destroy/demos/removed-block/generated/persist-removed.txtremoved-block-labsha256sum ~/terraform-labs/terraform-remove-resource-without-destroy/demos/removed-block/generated/persist-removed.txt26a5c306f3b0a9b4dfc39533ef090b2814f6da46f4ad386110f0cf42268d0fdd .../persist-removed.txtAfter state rm, the state-rm file keeps the same content:
cat ~/terraform-labs/terraform-remove-resource-without-destroy/demos/state-rm/generated/persist-rm.txtstate-rm-labFor provider-backed infrastructure, create a Docker volume, run terraform state rm, then inspect outside Terraform:
mkdir -p ~/terraform-labs/terraform-remove-resource-without-destroy/demos/docker-volume-rm
cd ~/terraform-labs/terraform-remove-resource-without-destroy/demos/docker-volume-rmcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
resource "docker_volume" "lab" {
name = "tf-forget-lab"
}
EOFterraform init -input=falseterraform apply -auto-approve -input=false -no-colorterraform state rm docker_volume.labdocker volume inspect tf-forget-lab[
{
"Name": "tf-forget-lab",
"Driver": "local",
"Mountpoint": "/var/lib/docker/volumes/tf-forget-lab/_data",
...
}
]The volume survived state rm. Destroy it during cleanup when you finish the lab.
What happens when configuration still contains the resource
Forgetting state does not remove the resource block from HCL. If the block remains, Terraform sees an untracked resource and plans to create it.
After state rm in demos/state-rm/:
terraform plan -no-color -input=false# local_file.persist will be created
+ resource "local_file" "persist" {
+ filename = "./generated/persist-rm.txt"
+ content = "state-rm-lab"
}
Plan: 1 to add, 0 to change, 0 to destroy.Terraform does not automatically reconnect to the existing file — it plans a fresh create. Applying that plan invokes the provider's create behavior. For local_file with the same path and content, the visible bytes may remain unchanged, but this is not import and should not be generalized as an adoption technique. Cloud resources can fail on name conflicts or create duplicates.
Remove the resource block after state rm so configuration matches the forgotten state. The config-cleanup demo shows the end state:
mkdir -p ~/terraform-labs/terraform-remove-resource-without-destroy/demos/config-cleanup/generated
cd ~/terraform-labs/terraform-remove-resource-without-destroy/demos/config-cleanupcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "persist" {
filename = "${path.module}/generated/persist-rm.txt"
content = "state-rm-lab"
}
EOFterraform init -input=falseterraform apply -auto-approve -input=false -no-colorterraform state rm local_file.persistRemove the resource block, leaving only the terraform block:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
EOFPlan reports no changes while the file remains on disk:
terraform plan -no-color -input=falseNo changes. Your infrastructure matches the configuration.The forgotten file is still readable:
cat ~/terraform-labs/terraform-remove-resource-without-destroy/demos/config-cleanup/generated/persist-rm.txtstate-rm-labCompare removed block and state rm
removed { lifecycle { destroy = false } } |
terraform state rm ADDRESS |
|
|---|---|---|
| Where it lives | Configuration in Git | One-off CLI |
| Runs on | terraform plan / apply |
Immediately |
| Team visibility | Same as other HCL changes | Easy to omit from runbooks |
| Provider destroy API | Not called when destroy = false |
Not called |
| After success | Remove removed block once all envs applied |
Delete or comment the resource block |
| Best for | Declarative “stop managing” in version control | Emergency repair when an immediate imperative state operation is required |
Both leave real infrastructure in place when used correctly. Prefer removed { lifecycle { destroy = false } } for planned changes because Terraform can preview the state removal before apply and the intent stays in version control. Reserve terraform state rm for cases where an immediate imperative state operation is specifically required and you already have a state backup.
Default destroy on a removed block is true — omitting destroy = false schedules a provider delete. Always set destroy = false explicitly when infrastructure must survive.
Move a resource to another Terraform configuration
Handoff is a two-root problem: stop managing in configuration A, start managing in configuration B, without deleting the real object.
The lab confirmed that local_file does not implement Terraform import. That makes it a poor example of a true cross-root adoption workflow. If the target root declares the same local_file after the source runs state rm, Terraform still sees an untracked resource and plans a create. Applying that plan invokes the provider's create behavior; matching filename and content may leave the file contents unchanged, but this is not import and should not be generalized as an adoption technique.
For a real handoff, use a resource that supports import. The Docker volume lab below demonstrates the sequence you can generalize to cloud resources:
- Source root manages the Docker volume
- Verify the volume exists outside Terraform
- Source runs
terraform state rm(or aremovedblock withdestroy = false) - Target root declares
docker_volumeand an import block - Target plan confirms import; apply records the existing object in target state
Source root — create and forget the volume binding:
mkdir -p ~/terraform-labs/terraform-remove-resource-without-destroy/demos/handoff-new-root/source
cd ~/terraform-labs/terraform-remove-resource-without-destroy/demos/handoff-new-root/sourcecat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
resource "docker_volume" "lab" {
name = "tf-handoff-lab"
}
EOFterraform init -input=falseterraform apply -auto-approve -input=false -no-colordocker volume inspect tf-handoff-labterraform state rm docker_volume.labTarget root — import the existing volume into a new state file:
mkdir -p ~/terraform-labs/terraform-remove-resource-without-destroy/demos/handoff-new-root/target
cd ~/terraform-labs/terraform-remove-resource-without-destroy/demos/handoff-new-root/targetcat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 3.0"
}
}
}
import {
to = docker_volume.lab
id = "tf-handoff-lab"
}
resource "docker_volume" "lab" {
name = "tf-handoff-lab"
}
EOFterraform init -input=falseterraform plan -no-color -input=false# docker_volume.lab will be imported
resource "docker_volume" "lab" {
id = "tf-handoff-lab"
name = "tf-handoff-lab"
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.terraform apply -auto-approve -input=false -no-colorApply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.A follow-up plan reports no drift — the target root now manages the existing volume. Destroy the volume from the target root when you finish the lab.
Never copy or delete whole terraform.tfstate files to split workspaces — use per-address state rm, import, or a controlled state migration when moving backends.
Common safety mistakes
| Mistake | Why it hurts | Safer approach |
|---|---|---|
Deleting terraform.tfstate |
Loses all resource tracking in that workspace | state rm one address or removed with destroy = false |
Removing HCL without removed / state rm |
Next apply destroys real infrastructure | Add removed { destroy = false } before deleting the block, or state rm then remove block |
state rm but leaving the resource block |
Plan wants to create again; risk of duplicates | Remove the block or re-import where supported |
Assuming apply after state rm reconnects |
Terraform plans create, not adopt | Import in target root when the provider supports it |
| Skipping independent verification | State says “gone” while object still exists — or vice versa after partial failure | cat, cloud console, docker volume inspect, etc. |
removed without destroy = false |
Default destroys through the provider | Set destroy = false explicitly |
No state backup before state rm |
Hard to undo a wrong address | terraform state pull first — see terraform state commands |
References
- removed block — HashiCorp Terraform language docs
- terraform state rm — HashiCorp Terraform CLI docs
- Import existing resources — HashiCorp Terraform language docs
- Resource lifecycle — HashiCorp Terraform language docs
Summary
You saw the default path first: delete a resource block without migration metadata and Terraform plans a destroy. That is the behavior you are overriding when real infrastructure must outlive Terraform management.
The two supported forget paths are declarative and imperative. A removed block with lifecycle { destroy = false } records the decision in Git and drops the address on apply without calling the provider delete API. terraform state rm does the same for one address immediately from the CLI. In both cases you verified files with cat and sha256sum, and a Docker volume with docker volume inspect, because Terraform output alone is not proof the object survived.
If the resource block stays in configuration after state rm, the next plan proposes create — remove the block or import into a new root when the provider supports it. A Docker volume handoff with an import block is the generalizable pattern; local_file is not importable and must not be treated as a cross-root adoption example. Do not delete terraform.tfstate to fix a single resource; use address-level tools and backups instead. For broader refactor patterns, continue with Terraform moved and removed blocks and terraform state commands.

