lesson ── terraform-intermediate ── ~14 мин ── 5 шагов
Renaming a resource or moving it into a module is routine work. Without declarative support Terraform sees "logs is gone, log_storage appeared" and does a destroy plus create. A bucket holding data gets destroyed.
The moved block (TF 1.1+) tells terraform "this is the same resource, the
address just changed". The plan shows "has moved", nothing is recreated.
It is the declarative replacement for terraform state mv, with git history
and PR review.
интерактивный sandbox
Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.
stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя
cd /home/student/tf-moved
cat > main.tf <<'EOF'
resource "random_id" "suffix" {byte_length = 4
}
resource "aws_s3_bucket" "logs" { bucket = "linuxlab-moved-${random_id.suffix.hex}" tags = {Project = "moved-demo"
}
}
EOF
terraform init
terraform apply -auto-approve
terraform state list
It should print:
aws_s3_bucket.logs
random_id.suffix
✓ The bucket is created under the address aws_s3_bucket.logs.
Just rename the address in HCL:
sed -i 's/"aws_s3_bucket" "logs"/"aws_s3_bucket" "log_storage"/' main.tf
grep "aws_s3_bucket" main.tf
terraform plan
In the output:
Terraform will perform the following actions:
# aws_s3_bucket.logs will be destroyed
# ...
- resource "aws_s3_bucket" "logs" { ... }# aws_s3_bucket.log_storage will be created
+ resource "aws_s3_bucket" "log_storage" { ... }Plan: 1 to add, 0 to change, 1 to destroy.
This is a destroy plus create. If it were a real bucket with data, the data would be gone (S3 destroy = remove). DO NOT APPLY right now.
This is exactly the problem that moved solves.
✓ You see destroy+create. That is bad. Now we add moved.
In main.tf, next to the resource:
moved {from = aws_s3_bucket.logs
to = aws_s3_bucket.log_storage
}
The final file:
resource "random_id" "suffix" {byte_length = 4
}
resource "aws_s3_bucket" "log_storage" { bucket = "linuxlab-moved-${random_id.suffix.hex}" tags = {Project = "moved-demo"
}
}
moved {from = aws_s3_bucket.logs
to = aws_s3_bucket.log_storage
}
terraform plan
Now the output:
Terraform will perform the following actions:
# aws_s3_bucket.logs has moved to aws_s3_bucket.log_storage
bucket = "linuxlab-moved-..."
Plan: 0 to add, 0 to change, 0 to destroy.
0 to destroy. That is the magic. See tf-moved-block.
✓ The moved block was understood. Apply will rename it in state with no destroy.
terraform apply -auto-approve
Apply does nothing in the cloud, it only updates state.
Check:
terraform state list
aws_s3_bucket.log_storage is now in state, aws_s3_bucket.logs is
gone. The address has changed.
In the cloud it is the same bucket:
aws --endpoint-url=http://localstack:4566 s3 ls | grep linuxlab-moved
aws --endpoint-url=http://localstack:4566 s3api get-bucket-tagging --bucket "$(aws --endpoint-url=http://localstack:4566 s3 ls | grep linuxlab-moved | awk '{print $3}')"The tags are in place, the bucket was not recreated, same creation_date and bucket-id (in LocalStack they are generated on the first create).
✓ The rename is done and the cloud resource is intact. That is what safe state refactoring looks like.
OpenTofu keeps the CLI and state compatible with Terraform for the
commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On a first switch, though, back
up the state and do a run on a feature branch, the differences
cluster in the newer features (variables in backend,
state encryption, OCI registry-backed modules). See
tf-opentofu-parity for the full matrix.
Once apply succeeds, the moved block has played its part. The options:
Remove it, which frees the code of "archival" blocks. terraform plan stays clean, because the HCL agrees with state.
Keep it as documentation: "this resource used to be named something else". On a large project that can be useful.
Experiment: remove the block and check the plan.
# remove the moved block
sed -i '/^moved {/,/^}/d' main.tfgrep -c "moved" main.tf || echo "no moved block"
terraform plan
Plan: No changes. The block can be removed, the refactor is finished.
As a team, agree on a policy: "remove right away" or "leave it until we do a cleanup PR two sprints later". The point is one consistent approach.
✓ The plan is clean and there is no moved block. The refactor is complete.
The most common non-rename use case. You had:
resource "aws_s3_bucket" "logs" {count = 3
bucket = "logs-${count.index}"}
Addresses: aws_s3_bucket.logs[0], [1], [2].
You want for_each for stable keys when one is removed:
variable "log_levels" {type = set(string)
default = ["debug", "info", "error"]
}
resource "aws_s3_bucket" "logs" {for_each = var.log_levels
bucket = "logs-${each.key}"}
moved { from = aws_s3_bucket.logs[0], to = aws_s3_bucket.logs["debug"] }moved { from = aws_s3_bucket.logs[1], to = aws_s3_bucket.logs["info"] }moved { from = aws_s3_bucket.logs[2], to = aws_s3_bucket.logs["error"] }Plan: 0 to destroy. The addresses change from indexed to keyed, the
cloud resources are the same.
Important: the bucket names change (logs-0 to logs-debug). For
S3 the name is immutable, so the plan shows a destroy on the bucket.
You either keep the old names (via bucket = "logs-${each.key == "debug" ? 0 : each.key == "info" ? 1 : 2}", which is ugly) or accept the recreation.
Usually count → for_each is done before the resources exist. If
they already exist, you weigh the cost of recreation against the cost
of living with count.
moved { from = ADDR_OLD, to = ADDR_NEW } in HCL is a declarative rename
of an address in state. The plan shows "has moved", the cloud is left
alone. After apply the block can be removed (or kept as documentation).
It works for a rename, a move into a module, and count to for_each.
команды
terraform planwith moved: you see 'has moved' instead of destroy+createterraform state listafter apply the address has changedконцепции