Objective
By the end of this lab you will have made Terraform destroy a resource
nobody asked it to destroy, by deleting one name from the middle of a
list. You will have the plan output that proves it, the same experiment
run against for_each for contrast, and a moved.tf that carries a
live count configuration across to for_each with a plan of
0 to add, 0 to change, 0 to destroy.
The point is not that for_each is better. You already read that in the
lesson. The point is to see the shape of the damage in plan output, so
that you recognise it in a pull request at 17:40 on a Friday.
Architecture
Two directories, one resource type, three names. The two configurations differ in exactly three lines.
count-vs-foreach/
├── count/
│ ├── main.tf count = length(var.services)
│ ├── services.auto.tfvars services = ["alpha", "bravo", "charlie"]
│ └── moved.tf (written in Task 7)
└── foreach/
├── main.tf for_each = toset(var.services)
└── services.auto.tfvars services = ["alpha", "bravo", "charlie"]
Each configuration writes one file per service into its own directory:
svc-alpha.conf, svc-bravo.conf, svc-charlie.conf. The resources are
files because files are cheap, local, and instantly inspectable. Read
every local_file in this lab as a database instance, a DNS record, or a
VM — something whose destruction is a change-freeze conversation rather
than a ls.
Requirements
- A Linux or macOS workstation with shell access and a writable
$HOME. - Terraform 1.9.x or later. Every output below was captured on Terraform v1.9.8 with hashicorp/local v2.9.0.
jq, used to read plan JSON.terraform show -jsonoutput is one very long line without it.- Outbound HTTPS to
registry.terraform.iofor the firstterraform initin each directory. After that the lab is offline. - No cloud account, no credentials, no cost. Nothing outside the two lab directories is written.
Confirm the version before you start — the moved block in Task 7 needs
Terraform 1.1 or later, and the plan wording below is 1.9.x:
terraform version
Scenario
A platform team manages per-service configuration with one Terraform
resource and a list of service names. The list has grown to eleven
entries over two years. This sprint, bravo is decommissioned, and the
change is a one-line diff: delete "bravo" from the list.
The reviewer approves it in forty seconds — it is a deletion of one
service, the diff is one line, and the plan summary at the bottom of the
CI comment is short. The apply takes down charlie as well.
Nobody made a mistake. The configuration did exactly what it says. Your job is to reproduce that, and to make the same one-line diff safe.
Tasks
Task 1: Scaffold both configurations
LAB="$HOME/count-vs-foreach"
mkdir -p "$LAB/count" "$LAB/foreach"
cd "$LAB/count"
Write main.tf:
terraform {
required_version = ">= 1.9.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "services" {
description = "One config file is created for each service in this list."
type = list(string)
}
resource "local_file" "service" {
count = length(var.services)
filename = "${path.module}/svc-${var.services[count.index]}.conf"
content = "service = ${var.services[count.index]}\n"
}
Write services.auto.tfvars beside it. The .auto.tfvars suffix means
Terraform loads it without a -var-file flag, which is what keeps every
command in this lab identical between the two directories:
services = ["alpha", "bravo", "charlie"]
Now produce the for_each variant. It is the same file with three lines
changed, and generating it with sed rather than retyping it is the
point — any behavioural difference you see later cannot be a typo
somewhere else:
cd "$HOME/count-vs-foreach/count"
sed -e 's/ count = length(var.services)/ for_each = toset(var.services)/' \
-e 's/var\.services\[count\.index\]/each.key/g' \
main.tf > ../foreach/main.tf
cp services.auto.tfvars ../foreach/
diff main.tf ../foreach/main.tf
diff should report exactly three changed lines: the meta-argument, the
filename, and the content.
Task 2: Apply the count configuration
cd "$HOME/count-vs-foreach/count"
terraform init
terraform plan -out=tfplan
The plan reports Plan: 3 to add, 0 to change, 0 to destroy. Apply the
saved plan file rather than re-planning — applying a plan you have
already read is the habit this course wants, and it means no
confirmation prompt stands between you and an apply you did not review:
$ terraform apply tfplanlocal_file.service[1]: Creating...
local_file.service[0]: Creating...
local_file.service[2]: Creating...
local_file.service[1]: Creation complete after 0s [id=351111a3680c108731c65b1d9cd559262cce7360]
local_file.service[0]: Creation complete after 0s [id=1bf0ed5ea7ba5124378abbf616f2e202a55b5636]
local_file.service[2]: Creation complete after 0s [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.Task 3: Record which address holds which service
This mapping is the whole lab. Write it down; you are going to compare against it twice.
$ terraform show -json | jq -r '.values.root_module.resources[] | "\(.address) -> \(.values.filename)"'local_file.service[0] -> ./svc-alpha.conf
local_file.service[1] -> ./svc-bravo.conf
local_file.service[2] -> ./svc-charlie.confThree addresses, three services, in list order. Nothing in the state
records that index 1 means bravo. The state records that index 1
currently has a filename of ./svc-bravo.conf, which is a different
claim, and the difference is the entire failure mode.
Task 4: Predict, then plan the decommission
Edit services.auto.tfvars to remove bravo — the one-line diff from
the scenario:
services = ["alpha", "charlie"]
Before running anything, write down your answers:
| Question | Your prediction | Observed |
|---|---|---|
| How many resources will be destroyed? | ||
| How many will be created? | ||
| Which addresses are touched? |
Now plan:
terraform plan -out=tfplan
$ terraform show -json tfplan | jq -r '.resource_changes[] | "\(.address) \(.change.actions | join(","))"'local_file.service[0] no-op
local_file.service[1] delete,create
local_file.service[2] deleteTwo destroys and one recreate, from deleting one name. The human-readable
plan explains why. The six content_* hash attributes are noise at this
width, so filter them out:
$ terraform show -no-color tfplan | grep -v 'content_'Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy
-/+ destroy and then create replacement
Terraform will perform the following actions:
# local_file.service[1] must be replaced
-/+ resource "local_file" "service" {
~ content = <<-EOT # forces replacement
- service = bravo
+ service = charlie
EOT
~ filename = "./svc-bravo.conf" -> "./svc-charlie.conf" # forces replacement
~ id = "351111a3680c108731c65b1d9cd559262cce7360" -> (known after apply)
# (2 unchanged attributes hidden)
}
# local_file.service[2] will be destroyed
# (because index [2] is out of range for count)
- resource "local_file" "service" {
- content = <<-EOT
service = charlie
EOT -> null
- directory_permission = "0777" -> null
- file_permission = "0777" -> null
- filename = "./svc-charlie.conf" -> null
- id = "78960e60a63de1af536f2ea4df319b6c5bbbd2a4" -> null
}
Plan: 1 to add, 0 to change, 2 to destroy.Read the two comment lines. They are Terraform telling you the truth in full:
local_file.service[1] must be replaced— index 1 used to bebravoand is nowcharlie.filenameis a force-new attribute forlocal_file, so Terraform cannot update it in place. It destroys what is at index 1 and builds a new thing there.local_file.service[2] will be destroyed (because index [2] is out of range for count)— index 2 heldcharlie. The list is now two long, so index 2 no longer exists, and the resource at it is removed.
charlie was never mentioned in the diff. charlie is destroyed, and
also rebuilt at a different address.
Task 5: Apply it and watch the log
$ terraform apply tfplanlocal_file.service[1]: Destroying... [id=351111a3680c108731c65b1d9cd559262cce7360]
local_file.service[2]: Destroying... [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
local_file.service[2]: Destruction complete after 0s
local_file.service[1]: Destruction complete after 0s
local_file.service[1]: Creating...
local_file.service[1]: Creation complete after 0s [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
Apply complete! Resources: 1 added, 0 changed, 2 destroyed.The end state on disk is correct — svc-alpha.conf and
svc-charlie.conf — and that is what makes this so hard to catch. The
declarative outcome is right. The path taken to it destroyed a resource
that was meant to survive.
Restore the third service before continuing. Put bravo back in
services.auto.tfvars:
services = ["alpha", "bravo", "charlie"]
$ terraform plan -out=tfplan && terraform apply tfplanlocal_file.service[1]: Destroying... [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
local_file.service[1]: Destruction complete after 0s
local_file.service[2]: Creating...
local_file.service[2]: Creation complete after 0s [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
local_file.service[1]: Creating...
local_file.service[1]: Creation complete after 0s [id=351111a3680c108731c65b1d9cd559262cce7360]
Apply complete! Resources: 2 added, 0 changed, 1 destroyed.Note what the rollback cost: charlie is destroyed and rebuilt a second
time. Reverting the pull request does not undo the damage — it repeats
it. That is worth knowing before an incident, because “revert the commit”
is the first thing anyone will suggest.
Task 6: The same edit against for_each
cd "$HOME/count-vs-foreach/foreach"
terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform state list
The state now reads by name, not by position:
local_file.service["alpha"]
local_file.service["bravo"]
local_file.service["charlie"]
Predict again, then make the identical edit to
foreach/services.auto.tfvars:
services = ["alpha", "charlie"]
$ terraform plan -out=tfplan && terraform show -json tfplan | jq -r '.resource_changes[] | "\(.address) \(.change.actions | join(","))"'local_file.service["alpha"] no-op
local_file.service["bravo"] delete
local_file.service["charlie"] no-opPlan: 0 to add, 0 to change, 1 to destroy, and the human-readable plan
names the reason in a way no reviewer can misread:
# local_file.service["bravo"] will be destroyed
# (because key ["bravo"] is not in for_each map)
Compare that comment with the one from Task 4. because index [2] is out of range for count requires the reader to hold the list in their head to
know which service index 2 was. because key ["bravo"] is not in for_each map states the service by name. The difference in review quality is
larger than the difference in syntax.
Apply it:
$ terraform apply tfplanlocal_file.service["bravo"]: Destroying... [id=351111a3680c108731c65b1d9cd559262cce7360]
local_file.service["bravo"]: Destruction complete after 0s
Apply complete! Resources: 0 added, 0 changed, 1 destroyed.One destroy. alpha and charlie were not read, not refreshed into a
change, and not touched.
Task 7: Migrate the count configuration without destroying anything
The count directory still has three live resources at integer
addresses. This is the position every team that inherits a count
configuration is in, and the migration is where the real risk sits — not
in the everyday edit.
First, do it the naive way. Edit count/main.tf so the resource block
matches the foreach one:
resource "local_file" "service" {
for_each = toset(var.services)
filename = "${path.module}/svc-${each.key}.conf"
content = "service = ${each.key}\n"
}
Plan it — and do not apply:
$ terraform plan -out=tfplan-naive && terraform show -json tfplan-naive | jq -r '.resource_changes[] | "\(.address) \(.change.actions | join(","))"'local_file.service[0] delete
local_file.service[1] delete
local_file.service[2] delete
local_file.service["alpha"] create
local_file.service["bravo"] create
local_file.service["charlie"] createPlan: 3 to add, 0 to change, 3 to destroy. Terraform has no way to know
that local_file.service[0] and local_file.service["alpha"] are the
same real resource. An address is an address. Applying this rebuilds the
entire estate.
rm tfplan-naive
Now do it properly. Add moved.tf in the same directory:
moved {
from = local_file.service[0]
to = local_file.service["alpha"]
}
moved {
from = local_file.service[1]
to = local_file.service["bravo"]
}
moved {
from = local_file.service[2]
to = local_file.service["charlie"]
}
The index-to-key mapping comes from the table you recorded in Task 3. There is no way to derive it after the fact if the configuration has already changed — which is why Task 3 said to write it down.
$ terraform plan -out=tfplanTerraform will perform the following actions:
# local_file.service[0] has moved to local_file.service["alpha"]
resource "local_file" "service" {
id = "1bf0ed5ea7ba5124378abbf616f2e202a55b5636"
# (10 unchanged attributes hidden)
}
# local_file.service[1] has moved to local_file.service["bravo"]
resource "local_file" "service" {
id = "351111a3680c108731c65b1d9cd559262cce7360"
# (10 unchanged attributes hidden)
}
# local_file.service[2] has moved to local_file.service["charlie"]
resource "local_file" "service" {
id = "78960e60a63de1af536f2ea4df319b6c5bbbd2a4"
# (10 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.0 to add, 0 to change, 0 to destroy is the only acceptable plan for a
refactor. Anything else means the mapping is wrong, and you want to find
that out here rather than after the apply.
$ terraform apply tfplanApply complete! Resources: 0 added, 0 changed, 0 destroyed.Applying a saved plan prints nothing but the summary — the three moves
were decided at plan time and are already recorded in tfplan. That is
the whole argument for the saved-plan workflow: what you reviewed is what
runs.
Confirm the addresses moved and the files never went anywhere:
terraform state list
ls -1 svc-*.conf
Finally, delete moved.tf and plan once more. A moved block whose
from no longer exists in state is silently ignored, so leaving it does
no harm — but it is a statement about a migration that has already
happened, and stale statements accumulate:
rm moved.tf
terraform plan
No changes. Your infrastructure matches the configuration.
Validation
Work through these in order. Each one is a command with a stated result, not a feeling that the lab went well.
- In
count/,terraform show -json | jq -r '.values.root_module.resources[].address'returns three addresses and every one is quoted-string-keyed (local_file.service["alpha"]), not integer-keyed. ls -1 svc-*.confincount/lists exactlysvc-alpha.conf,svc-bravo.conf,svc-charlie.conf.terraform planincount/, withmoved.tfdeleted, reportsNo changes. Your infrastructure matches the configuration.- Your prediction table from Task 4 records the observed result as
1 create and 2 destroys, and names
local_file.service[2]as a destroy you did not ask for. - Your prediction table from Task 6 records 0 creates and 1 destroy,
and names
local_file.service["bravo"]as the only address touched. - In
foreach/,terraform state listreturns two addresses, andsvc-bravo.confis absent from the directory. - You can state, without looking it up, why the naive migration in Task 7 planned six changes rather than zero.
Expected Outcome
count-vs-foreach/
├── count/
│ ├── .terraform.lock.hcl
│ ├── main.tf for_each, migrated in Task 7
│ ├── services.auto.tfvars three services
│ ├── svc-alpha.conf
│ ├── svc-bravo.conf
│ ├── svc-charlie.conf
│ ├── terraform.tfstate three string-keyed addresses
│ └── tfplan
└── foreach/
├── .terraform.lock.hcl
├── main.tf
├── services.auto.tfvars two services
├── svc-alpha.conf
├── svc-charlie.conf
└── terraform.tfstate two string-keyed addresses
Both directories now key their instances by service name. You have watched an unrequested destroy appear in a plan, you know the two comment lines that announce it, and you have run the migration that prevents it on a configuration that was already live.
Troubleshooting
The given "for_each" argument value is unsuitable. The full error
reads:
The given "for_each" argument value is unsuitable: the "for_each" argument
must be a map, or set of strings, and you have provided a value of type list
of string.
for_each does not accept a list, because a list has positions and
for_each needs keys. toset(var.services) converts it. If the elements
are objects rather than strings, build a map instead with a for
expression keyed on a stable field.
toset() silently deduplicated my list. toset(["alpha", "alpha", "bravo"]) is a two-element set. The same list under
count = length(var.services) creates three instances. Migrating a
count configuration whose list contains duplicates therefore changes
the instance count, and the moved mapping will not line up. Check first
with terraform console:
echo 'length(toset(var.services)) == length(var.services)' | terraform console
false means you have duplicates to resolve before migrating.
The plan with moved.tf is not 0/0/0. The mapping is wrong. Either
an index is paired with the wrong key, or the configuration change and
the moved blocks disagree about which keys exist. Recover the true
mapping from the state backup — terraform show -json terraform.tfstate.backup
— rather than guessing from the file names on disk, which the failed plan
may already have changed.
Terraform prompts me for var.services. The tfvars file is not being
loaded. It must be named exactly services.auto.tfvars or
terraform.tfvars; any other name needs -var-file. A file called
services.tfvars is loaded by nobody and produces exactly this prompt.
Error: Invalid single-argument block definition. You compressed the
variable block onto one line. HCL allows a single-line block with one
argument only; type and default together need the multi-line form.
The moved block errors with an address that is not in the
configuration. The to address must correspond to a resource block
that exists in the current configuration. Moving to a resource you have
deleted is not a move, it is a removal — that is what
removed blocks and terraform state rm are for.
I applied the naive migration by mistake. For local_file the cost
is three rewritten files. For anything with data behind it, stop the
apply, do not re-run, and restore state from
terraform.tfstate.backup before deciding anything — Terraform writes
that file immediately before each state change.
Cleanup
Everything this lab created lives under one directory. No system configuration was changed, no service was contacted, and no privilege was escalated.
Step 1. Destroy the managed resources in both directories, so state and disk agree before anything is deleted:
$ terraform -chdir="$HOME/count-vs-foreach/count" destroy -auto-approvelocal_file.service["bravo"]: Destroying... [id=351111a3680c108731c65b1d9cd559262cce7360]
local_file.service["charlie"]: Destroying... [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
local_file.service["alpha"]: Destroying... [id=1bf0ed5ea7ba5124378abbf616f2e202a55b5636]
local_file.service["alpha"]: Destruction complete after 0s
local_file.service["charlie"]: Destruction complete after 0s
local_file.service["bravo"]: Destruction complete after 0s
Destroy complete! Resources: 3 destroyed.Then the same for the other directory, which holds two:
$ terraform -chdir="$HOME/count-vs-foreach/foreach" destroy -auto-approvelocal_file.service["alpha"]: Destruction complete after 0s
local_file.service["charlie"]: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.Step 2. Keep the prediction table — it is the deliverable, and it is the only part of this lab that is yours:
mkdir -p "$HOME/terraform-lab-deliverables"
cp "$HOME/count-vs-foreach/predictions.md" \
"$HOME/terraform-lab-deliverables/count-vs-foreach-predictions.md"
Step 3. Confirm what you are about to delete, then delete it.
ls -la "$HOME/count-vs-foreach"
rm -rf "$HOME/count-vs-foreach"
The provider plugin cache under ~/.terraform.d/ is shared and is left
alone deliberately; removing it only forces the next terraform init
anywhere on this machine to re-download.
What You Learned
- You reproduced the index shift, and the damage was larger than the edit: removing one of three services produced one replacement and two destroys, one of them on a service the change never named.
- The plan summary line hides which resources are affected.
terraform show -json tfplan | jqprints one line per address and is short enough to paste into a review.2 to destroyandlocal_file.service[2] deleteare the same fact with completely different review outcomes. - The two plan comments are the diagnosis.
because index [2] is out of range for countandbecause key ["bravo"] is not in for_each maptell you which identity model you are looking at before you read another line. - Reverting does not undo an index shift. Putting
bravoback destroyed and rebuiltcharliea second time. - The naive
count-to-for_eachedit plans a full rebuild, because Terraform matches by address and every address changed. Threemovedblocks turned that into0 to add, 0 to change, 0 to destroy. for_eachis not free.toset()deduplicates, so a list with repeats produces fewer instances thancountdid; keys must be strings; and the key you choose becomes an identity you cannot change later without a destroy.for_eachmoves the failure from “positions shift” to “choose a stable key”, which is a much better problem, but it is still a problem you have to think about once.