All articles

Refactor Terraform without accidentally replacing infrastructure

A local moved-block lab, followed by the harder questions: state separation, shared outputs, access boundaries and coordinated changes.

A resource address is part of the change

Renaming a resource block looks like a source-code cleanup. To Terraform, an address helps identify which configured object corresponds to which managed object. A refactor needs to preserve that relationship deliberately.

This article starts with a local-only example using terraform_data, then separates address changes from migrations between states. Use Terraform 1.4 or newer for the lab, in a new disposable directory with local state. It needs no cloud provider or cloud credentials. It does create a local Terraform-managed object and state files.

The review target is narrow: keep the object’s input unchanged while moving from a single resource address to a named for_each instance. Changing an address and changing infrastructure behavior in the same review makes an unexpected replacement much harder to explain.

Create a small baseline

Save the following as main.tf in the empty lab directory:

terraform {
  required_version = ">= 1.4.0"
}

resource "terraform_data" "service" {
  input = {
    name = "api"
    tier = "application"
  }
}
terraform init
terraform plan
terraform apply
terraform state list

Review the plan and approve the apply only in this disposable lab. The listed address should include terraform_data.service. The built-in resource stores data and participates in Terraform’s lifecycle without creating an external cloud service; see the terraform_data reference.

Keep the initial configuration in version control if you want to compare the refactor, but keep the state files out of it. The lab’s simplicity is intentional: if the plan later proposes replacement, there is little unrelated configuration to obscure the cause.

Record the address change explicitly

Replace the contents of main.tf with this configuration. The object data remains the same; its address becomes terraform_data.service["api"].

terraform {
  required_version = ">= 1.4.0"
}

resource "terraform_data" "service" {
  for_each = toset(["api"])

  input = {
    name = each.key
    tier = "application"
  }
}

moved {
  from = terraform_data.service
  to   = terraform_data.service["api"]
}
terraform fmt -check
terraform validate
terraform plan -out=refactor.tfplan
terraform show refactor.tfplan

The expected plan records the move without adding, changing or destroying the underlying lab resource. If your result differs, stop and compare the actual input, Terraform version and state. After review, terraform apply refactor.tfplan records the address change. Check terraform state list again.

moved blocks let a configuration describe address refactors. Preserve the necessary move history for users upgrading from older module versions; removing it can break their upgrade path. The behavior is documented in module refactoring.

Moving an address is not splitting a state

The lab stays within one state. A moved block does not transfer ownership between independent backends. Treat a split between a shared network state and an application state as a separate migration with explicit ownership handover.

A migration plan should identify the old and new owners, the exact objects, the provider identities and the interval during which automated runs are paused. Prove the destination configuration matches the existing objects and that neither side will propose unwanted creation, replacement or deletion. Do not simply copy files and run both roots.

Before the handover, rehearse the procedure against a representative non-production environment and define how to recover if the process stops halfway. Afterward, review plans for both roots and ensure each real object has one intended managing state. This is an operational review framework, not a universal state-migration command sequence.

Shared outputs are also an access decision

An application may need a network identifier without needing permission to inspect every resource in the network state. terraform_remote_state exposes root outputs to configuration, but the reader needs access to the underlying state snapshot. It is therefore not an output-only security boundary. HashiCorp discusses alternatives in the remote-state data-source documentation.

For a boundary with different trust levels, consider publishing the required non-secret identifiers to an appropriate configuration store with narrowly scoped access. Specify who writes the values, how consumers find them and how an interface change is rolled out. The store itself is not a substitute for a versioned agreement.

A small contract might include a network ID, a set of subnet IDs and an interface version. Require the consuming team to understand what a changed value means. If a producer replaces a network, an unchanged output name can still hide a significant consumer impact.

A lock protects one state, not the whole platform

Backend locking support varies. Where supported, a lock coordinates operations against that state; it does not create a transaction spanning several independent states. Check the backend documentation and design cross-state sequencing explicitly.

For a producer-consumer change, review the producer’s intended update, the published interface and the consumer’s fresh plan in order. Avoid approving an old consumer plan on the assumption that upstream values have not changed. Record which configuration revision and environment each approval describes.

State and saved plans can contain sensitive data. Protect the lab artifacts and use appropriately controlled storage for real workflow artifacts; see sensitive-data handling. In the disposable lab, terraform destroy removes the demonstration object after plan review; it does not remove the working directory.

The goal is not to eliminate refactoring. It is to make identity, ownership and impact independently visible so a clean-looking code change cannot silently become an infrastructure replacement.

References

Official documentation. Match commands and behavior to your installed versions.

Continue with a practical course

Explore the related video lessons and build on these foundations.

Explore the course