linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Введение
  • Уроки
  • How it works
  • База знаний
  • Шпаргалка
  • Capstone
  • Собеседование
home/terraform/lessons/tf-intermediate-09-moved-block

lesson ── terraform-intermediate ── ~14 мин ── 5 шагов

The moved block: refactoring without recreation

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 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Create the resource under its old name

    bash
    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.

  2. 02

    Try renaming WITHOUT moved: you get a destroy

    Just rename the address in HCL:

    bash
    sed -i 's/"aws_s3_bucket" "logs"/"aws_s3_bucket" "log_storage"/' main.tf
    grep "aws_s3_bucket" main.tf
    bash
    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.

  3. 03

    Add the moved block

    In main.tf, next to the resource:

    hcl
    moved {
      from = aws_s3_bucket.logs
      to   = aws_s3_bucket.log_storage
    }

    The final file:

    hcl
    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
    }
    bash
    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.

  4. 04

    Apply: the address changes, the bucket survives

    bash
    terraform apply -auto-approve

    Apply does nothing in the cloud, it only updates state.

    Check:

    bash
    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:

    bash
    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.

    The same thing on OpenTofu

    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.

    • → OpenTofu parity
  5. 05

    The moved block can stay or go

    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.

    bash
    # remove the moved block
    sed -i '/^moved {/,/^}/d' main.tf
    grep -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.

    moved for count to for_each

    The most common non-rename use case. You had:

    hcl
    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:

    hcl
    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.

    • → The full moved block
    • → Refactoring patterns

Что ты узнал

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+create
  • terraform state listafter apply the address has changed

концепции

  • · moved is a state operation: the cloud resource does not change
  • · The resource type in from and to must match: moved is not for changing the type
  • · A PR with a moved block shows up in the diff: the refactor is visible to the team

← предыдущий

lifecycle: blocking, ignoring, and recreating

следующий →

OIDC, an IAM role for CI without access keys

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies