Removed cannot take one item out of a for_each. Moved can.
A list drives a for_each, prevent_destroy guards every item, and one item has to leave Terraform while the real thing stays in place. Three obvious attempts fail, and the command that works skips review. One moved block and one removed block do it in a reviewed pull request, with a plan that shows nothing destroyed.
A list of repositories in a JSON file drives a for_each, and prevent_destroy guards every one of them. Then one repository has to leave Terraform. It moves to another team’s configuration, or it is archived and should stop being reconciled. The repository itself has to stay in place.
This is the first part of a series on Terraform state surgery, which means changing what Terraform manages without destroying anything live, with the plan as the proof. Everything below was tested on Terraform 1.8 and 1.17. The removed block it relies on arrived in 1.7.
The setup
The configuration reads the list and creates one repository per entry:
locals {
repos = jsondecode(
file("repos.json")
).repositories
}
resource "github_repository" "repo" {
for_each = toset(local.repos)
name = each.key
lifecycle {
prevent_destroy = true
}
}
prevent_destroy makes any plan that would delete a repository fail, including a plan that only means to stop managing one. The goal is to drop acme-docs from the list and from the state, and to keep the repository.
Three attempts that fail
Each of the obvious approaches either stops at an error or destroys the repository.
Delete the entry from the list
Terraform reads a missing key as “this repository should not exist” and plans a destroy. The guard stops it:
Plan: 0 to add, 0 to change, 1 to destroy.
Error: Instance cannot be destroyed
Resource github_repository.repo["acme-docs"] has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan, either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target option.
A removed block on the key
A removed block tells Terraform to forget an object without deleting it. It is the obvious tool, and it refuses an instance key:
removed {
from = github_repository.repo["acme-docs"]
lifecycle {
destroy = false
}
}
Error: Resource instance keys not allowed
Resource address must be a resource (e.g. "test_instance.foo"), not a resource instance (e.g. "test_instance.foo[1]").
The documentation says so in one line.
You cannot include instance keys, such as
"aws_instance.example[1]", if the resource is configured to provision multiple instances.
Terraform: Remove a resource from state
A removed block on the whole resource is allowed, but the resource block has to go with it, and Terraform forgets every repository in the list.
Switch the guard off for one run
The first error message suggests disabling prevent_destroy. Without the guard the plan passes, and it plans 1 to destroy. The apply deletes the repository.
The command that skips review
terraform state rm 'github_repository.repo["acme-docs"]' does the job. It is also a command that someone runs with write access to the state, outside the pull request. No reviewer sees it before it happens, and no plan records what it did.
HashiCorp recommends the removed block over state rm, because the block “lets you preview the results of the operation”. In a configuration that one person applies without review, state rm is enough.
Moved, then removed
A moved block can take one instance out of a for_each and give it an address of its own. The refactoring guide shows a similar move, from aws_instance.d[2] to aws_instance.d. Once the object has a standalone address, a removed block accepts it:
moved {
from = github_repository.repo["acme-docs"]
to = github_repository.repo_acme_docs
}
removed {
from = github_repository.repo_acme_docs
lifecycle {
destroy = false
}
}
These two blocks and the deleted list entry go in one pull request. The plan shows the result a reviewer needs:
Plan: 0 to add, 0 to change, 0 to destroy.
Warning: Some objects will no longer be managed by Terraform
If you apply this plan, Terraform will discard its tracking information for the following objects, but it will not delete them:
- github_repository.repo_acme_docs
After the apply, the state holds the remaining repositories, and acme-docs is untouched on GitHub.
Try it without a provider
The same steps work with terraform_data, which needs no provider and no cloud account. Put it in a for_each over ["a", "b", "c"] with prevent_destroy, apply, then drop "b" and add the two blocks with terraform_data.repo["b"] and terraform_data.repo_b. The first two attempts fail with the same errors as above, and the fix plans 0 to destroy.
Address names and related resources
Two details decide whether the fix works the first time.
The new address has to be a valid name
The to address is a resource name. Names allow letters, digits, underscores and dashes, and cannot start with a digit. A repository called acme.github.io fails, because Terraform reads each dot as a step into the address:
Error: Invalid address
Unexpected extra operators after address.
A fixed rule avoids it. Prefix repo_, lowercase the name and turn every character that is not a letter or digit into an underscore, so acme.github.io becomes repo_acme_github_io. The prefix also covers names that start with a digit.
Every resource keyed by the item needs its own pair
Other resources are often keyed by the same name. Branch rulesets, team permissions and webhooks are often keyed by the same name, sometimes with a suffix such as acme-docs-admin. Each of them needs its own moved and removed pair, or the plan destroys them while the repository survives. List them before writing the blocks:
terraform state list | grep acme-docs
The second pull request
A second pull request deletes the two blocks. It has to wait until every state that uses this configuration has applied the first one. In a state that has not, the entry and the blocks are both gone. Its plan tries to destroy the repository, fails on prevent_destroy, and keeps failing until the blocks come back.
Leaving the blocks in place breaks the configuration later. If someone adds acme-docs back to the list, every plan for that root module fails:
Error: Moved object still exists
This statement declares a move from github_repository.repo["acme-docs"], but that resource instance is still declared at main.tf:8,14.
The same error appears if the blocks land while the entry is still in the list. So the entry and the blocks go in one pull request.
The refactoring guide recommends keeping historical moved blocks. That advice is for shared modules, where callers upgrade from older versions. It says removal “can be safe” for private modules once every user has applied the new version. A root module whose list can bring the key back is the case where removal is required.
What this bought, and what stays manual
The change that takes a production object out of Terraform now goes through the same pull request as every other change. A reviewer reads “0 to destroy” and the list of objects Terraform will stop tracking before anything happens. Posted as a comment on the pull request, the plan stays with the change as the record.
The price is two pull requests where state rm takes one command, and a pair of blocks for every resource keyed by the item.
Two things stay manual. Someone has to open the second pull request once every state has applied the first. A repository that returns to this configuration comes back through an import block, because nothing tracks it in the meantime.
Part 1 of a series on Terraform state surgery without downtime.