Reading Another State and Refactoring: remote_state, moved, removed

K
Kai··5 min read

The previous article split each environment into its own directory with its own state. That created a need: the compute layer needs the VPC id that the network layer created in a different state. This article solves it with terraform_remote_state, then handles two refactoring situations you always hit when reorganizing code — renaming a resource and dropping a resource from management — using the moved and removed blocks, declaring them in configuration instead of the manual commands seen in Article 7.

Goal

Read one configuration's output from another via terraform_remote_state, and refactor safely with moved (rename/move) and removed (drop from state) right in code.

terraform_remote_state: reading another state's output

When you split into multiple states, each state publishes some outputs (Article 9). terraform_remote_state is a data source that reads the output of another state. A "network" configuration creates a shared bucket and publishes its name:

# network/main.tf
resource "aws_s3_bucket" "shared" {
  bucket_prefix = "tf-series-bai16-net-"
  force_destroy = true
}

output "bucket_name" {
  value = aws_s3_bucket.shared.id
}

The "consumer" configuration reads that output:

# consumer/main.tf
data "terraform_remote_state" "network" {
  backend = "local"
  config = {
    path = "../network/terraform.tfstate"
  }
}

output "consumed_bucket" {
  value = data.terraform_remote_state.network.outputs.bucket_name
}

Access it via data.terraform_remote_state.<name>.outputs.<output_name>. Apply the consumer:

$ terraform apply -auto-approve
$ terraform output
consumed_bucket = "tf-series-bai16-net-2026..."

The consumer picks up the bucket name that network created, using it as if it were its own variable. Here we use the local backend pointing straight at the state file for brevity; in practice you'd set backend = "s3" with the same bucket/key as the network layer (the actual remote state from Article 6).

One important thing about boundaries: terraform_remote_state can only read what the other state publishes via outputs, not every resource inside it. That boundary is deliberate: outputs become the public contract between layers, while the internal details of each layer stay private. When you change the network layer's internal structure but keep the outputs the same, the consumer is unaffected.

moved block: rename without destroy-recreate

Article 7 renamed a resource with the manually typed terraform state mv command. That approach has a downside: it's a one-off operation on the runner's machine, not stored in code, so colleagues and CI don't know a rename happened. The moved block (Terraform 1.1) declares the rename in configuration.

You have a bucket named old_name and want to rename it to new_name. If you just change the name, Terraform thinks it should destroy the old one and create a new one. Add a moved block:

moved {
  from = aws_s3_bucket.old_name
  to   = aws_s3_bucket.new_name
}

resource "aws_s3_bucket" "new_name" {
  bucket_prefix = "tf-series-bai16-"
  force_destroy = true
}
$ terraform plan
  # aws_s3_bucket.old_name has moved to aws_s3_bucket.new_name

Plan: 0 to add, 0 to change, 0 to destroy.

has moved to, and 0 to add, 0 to change, 0 to destroy — no touching real infrastructure, just changing the address in state. Before planning the resource at to, Terraform checks state to see whether a resource exists at the from address; if so, it treats them as the same object and only changes the name.

The difference from state mv: the moved block lives in code, so it's reviewed through a pull request, runs consistently everywhere, and applies to other people and to CI without anyone typing a manual command. Once every state has applied the rename, you can delete the moved block.

removed block: drop from state without destroy

Similarly, Article 7 used state rm manually to drop a resource from management while keeping the infrastructure. The removed block (Terraform 1.7) declares that. Drop the resource block and replace it with removed:

removed {
  from = aws_s3_bucket.new_name
  lifecycle {
    destroy = false
  }
}
$ terraform apply -auto-approve
  # aws_s3_bucket.new_name 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.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

lifecycle { destroy = false } is the deciding part: drop from state but don't destroy the real object. Verify the bucket still exists:

$ aws s3api head-bucket --bucket tf-series-bai16-2026...
{
    "BucketArn": "arn:aws:s3:::tf-series-bai16-2026...",
    "BucketRegion": "ap-southeast-1"
}

The bucket is intact, Terraform just no longer manages it — exactly like state rm, but declared in code. (If you set destroy = true, or simply delete the resource block without a removed block, Terraform will destroy the object — that's a difference to be careful about.)

   moved                              removed (destroy = false)
   ─────                              ─────────────────────────
   from = old_name                    from = new_name
   to   = new_name                    lifecycle { destroy = false }
        │                                  │
   state: change address               state: drop from management
   infra: NOT touched                  infra: KEPT (not destroyed)

Why use blocks instead of manual commands

The moved/removed blocks and state mv/state rm do the same thing, but blocks win on being configuration: versioned in git, reviewed through a PR, applied identically on every machine and in CI. The manual state command is an immediate operation, easy to forget recording, easy to run differently from one person to the next. For refactoring that touches state — something sensitive — declaring it in code is much safer. Manual commands are still useful for one-off emergency operations, but for planned refactoring, use blocks.

🧹 Cleanup

The bucket dropped with removed (orphaned) has to be deleted manually; the network/consumer part is destroyed normally:

$ aws s3 rb s3://tf-series-bai16-... --force      # bucket orphaned after removed
$ cd network && terraform destroy -auto-approve
Destroy complete! Resources: 1 destroyed.

Wrap-up

terraform_remote_state lets one configuration read the output (only the output) of another via data.terraform_remote_state.<name>.outputs.<x>, turning outputs into a contract between infrastructure layers. The moved block (1.1) renames/moves a resource in state without destroy-recreate (has moved to, 0 destroy). The removed block (1.7) with lifecycle { destroy = false } drops a resource from management but keeps the real object. Both are declared in code, so they're reviewable and reproducible, safer than manual state mv/state rm for planned refactoring.

Part V closes here. Part VI gathers the lifecycle and provider advanced features we've touched on here and there: create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, multi-region provider aliases, terraform_data replacing null_resource, provisioners as a last resort, and the check block. All in Article 17.