Overview
When refactoring Terraform code, it’s common to move existing resources from the root module into a dedicated submodule for better organization and reusability. However, without the right approach, Terraform may interpret this as destroying and recreating resources, which can cause downtime or data loss.
Terraform provides the moved block feature to record these refactors safely. This ensures that Terraform understands the resource was moved, not destroyed and recreated.
Problem
Directly moving a resource definition into a module without telling Terraform about the change results in a destroy + recreate plan, even though the resource already exists.
For example:
Resource is first defined in the root module. You later move it into a module for code organization. Terraform doesn’t automatically know the resource was moved and plans a replacement.
Cause
Terraform tracks resources in its state file by their address (e.g., aws_s3_bucket.logs).
When you refactor code into a module, the state address changes (e.g., module.logs_bucket.aws_s3_bucket.this). Terraform interprets this as a different resource unless explicitly instructed otherwise.
Solution
To migrate resources into a module without resource recreation:
1. Define the resource in the root module (current state)
# root/main.tf
resource "aws_s3_bucket" "logs" {
bucket = "acme-logs-prod"
}
2. Create a module for the resource
# modules/s3_bucket/main.tf
variable "name" { type = string }
resource "aws_s3_bucket" "this" {
bucket = var.name
# ...rest of your config...
}
3. Reference the module from the root
# root/main.tf
module "logs_bucket" {
source = "./modules/s3_bucket"
name = "acme-logs-prod"
}
4. Add a moved block at the root module
# root/moved.tf (file name is arbitrary)
moved {
from = aws_s3_bucket.logs
to = module.logs_bucket.aws_s3_bucket.this
}
This tells Terraform to map the old resource address to the new one.
5. Run the migration commands
terraform init
terraform plan # should show a "moved" action, not create/destroy
terraform apply
Terraform will now update the state reference with zero churn.
Notes & Tips
Multiple resources: If the module splits the resource into multiple sub-resources (common with AWS provider updates), add a moved block for each one:
moved { from = aws_s3_bucket_public_access_block.logs
to = module.logs_bucket.aws_s3_bucket_public_access_block.this }
moved { from = aws_s3_bucket_versioning.logs
to = module.logs_bucket.aws_s3_bucket_versioning.this }
- For resources with for_each or count: Include the key/index:
moved {
from = aws_s3_bucket.logs["prod"]
to = module.logs_bucket.aws_s3_bucket.this["prod"]
}
- Ensure that the resource address specified in the moved block exactly matches the address of the resource inside the module.
Keep moved blocks: Leave them in the codebase for a while so that CI/CD pipelines and teammates don’t accidentally regress. You can remove them once the state transition is fully adopted.
References