The Terraform configuration had grown into one directory with sixty resources and no structure, and every attempt to organise it produced a plan that wanted to destroy and recreate the things being moved. The refactor had been deferred twice because nobody wanted to be the person who ran an apply that dropped a database.
The symptom
$ terraform plan
# aws_db_instance.main will be destroyed
- identifier = "shop-production" -> null
# module.database.aws_db_instance.this will be created
+ identifier = "shop-production"
Plan: 1 to add, 0 to change, 1 to destroy.
# the same database, moved into a module — and Terraform
# has no idea they are the same thing.Terraform tracks a resource by its address in the configuration, so moving it is indistinguishable from deleting one and creating another. The plan is accurate and the consequence would be a new empty database and a very bad afternoon.
Why it happens
The state file maps a configuration address to a real resource id, and nothing in the configuration expresses that an address used to be something else. The relationship exists only in somebody’s head at the moment of the rename.
The fix
The moved block
moved {
from = aws_db_instance.main
to = module.database.aws_db_instance.this
}
# and the plan becomes:
# Plan: 0 to add, 0 to change, 0 to destroy.
#
# module.database.aws_db_instance.this has moved to
# module.database.aws_db_instance.this
An empty plan after a large rename is the acceptance criterion and is the thing that could not previously be achieved. The block is declarative, appears in the plan output, and is therefore reviewed by a second person — which is the substantive difference from the command it replaces.
# what it replaces: correct, and outside review
$ terraform state mv aws_db_instance.main
module.database.aws_db_instance.this
Successfully moved 1 object(s).
# run by one person, on a laptop, against production
# state — no record in the repository, no review, and no
# way to reproduce it in another workspace.The state mv form also has to be run once per workspace, so a rename applied to production and forgotten in staging leaves the two configurations diverged with no signal. A moved block is in the configuration and therefore applies everywhere the configuration does.
What it can and cannot express
# a rename, and into a module
moved { from = aws_s3_bucket.uploads to = aws_s3_bucket.media }
moved { from = aws_s3_bucket.media to = module.storage.aws_s3_bucket.this }
# count to for_each — one entry per instance
moved { from = aws_instance.web[0] to = aws_instance.web["a"] }
moved { from = aws_instance.web[1] to = aws_instance.web["b"] }
# a whole module
moved { from = module.db to = module.database }
# what it CANNOT do: change the resource TYPE, move between
# state files, or move something not already in state
The count-to-for_each conversion is the case that makes this worth having on its own, because it is otherwise the most dangerous refactor available — index-based addresses shift when an element is removed, so changing the collection type without moved blocks reshuffles every instance. That is a rebuild of every server in the set.
Being unable to change the resource type is the limitation that matters, and it is the case where an import and a manual state edit remain the only options. A provider that renames a resource across a major version is exactly this situation and is documented in the provider’s upgrade guide rather than solved by the language.
Refactoring into modules, which is what this enables
before: one directory, 60 resources, 1,400 lines
after: main.tf, plus
modules/network/ vpc, subnets, security groups
modules/database/ rds, parameter group, backups
modules/compute/ instances, launch template, asg
modules/storage/ buckets, lifecycle rules
moved.tf ← 41 blocks, one release
and the plan after: 0 to add, 0 to change, 0 to destroy.Putting every moved block in one file rather than beside the resources is what makes the release reviewable — forty-one blocks scattered through four modules is a diff nobody reads, and one file with a comment saying which release it belongs to is a diff somebody can check.
# the module encodes a decision rather than forwarding
# eleven variables
variable "size" {
type = string # small | medium | large
validation {
condition = contains(["small", "medium", "large"], var.size)
error_message = "size must be small, medium or large."
}
}
locals {
sizing = {
small = { instance_class = "db.t3.small", storage = 50 }
medium = { instance_class = "db.t3.medium", storage = 100 }
large = { instance_class = "db.r6g.large", storage = 500 }
}
}
A module that says a medium database means this instance class, this storage and this backup window is worth having; one that forwards eleven variables is the same configuration with an extra indirection. The validation block turns an invalid size into a plan-time error with a readable message rather than a provider error three minutes later.
Removing the blocks, eventually
a moved block is needed until every state has been applied
through it. after that it is history.
the policy that stuck:
moved.tf carries a header naming its release and a date
blocks are removed in the release AFTER every workspace
has applied — checked, not assumed
removing one a workspace has not applied means that
workspace plans a destroy, so the check is:
for each workspace, terraform plan -detailed-exitcode
must return 0 before the file is deleted.Leaving them forever is the path of least resistance and produces a file of forty blocks describing history nobody needs, which is a small cost. Removing one prematurely is a much larger one, and the exit-code check across every workspace is the only thing that distinguishes the two situations.
Verifying it worked
$ for ws in production staging; do
> terraform workspace select "$ws"
> terraform plan -detailed-exitcode >/dev/null; echo "$ws: $?"
> done
production: 0
staging: 0
$ terraform state list | head -4
module.compute.aws_autoscaling_group.this
module.database.aws_db_instance.this
module.network.aws_vpc.this
module.storage.aws_s3_bucket.this
$ aws rds describe-db-instances
--db-instance-identifier shop-production
--query 'DBInstances[0].InstanceCreateTime'
"2019-04-11T08:22:04Z" # untouchedThe instance creation time being unchanged is the assertion that nothing was recreated, and it is worth checking directly rather than trusting the plan — a plan that reports no changes and a resource that was replaced would be a very unusual bug and is the outcome this whole exercise exists to avoid.
What this costs
Blocks that accumulate and are never removed, because removing them requires verifying every workspace and there is no natural moment to do it. A year later the file has fourteen entries describing renames nobody remembers, which is harmless clutter and is the honest outcome for most teams.
The module refactor also introduces a versioning question that a flat directory did not have. Modules in the same repository move with it, and the moment one is extracted to be shared, every caller pins a version and upgrading becomes a coordinated change — which is the same trade as any shared library and is worth deferring until a second consumer actually exists.