Safely refactor Terraform modules using moved blocks to rename resources, reorganize module structure, and migrate state without destroying infrastructure...
Safely refactor Terraform modules using moved blocks to update resource addresses without destroying existing infrastructure. This skill focuses on preserving state while improving code organization.
Terraform Version: >= 1.1
For older versions, use the terraform state mv CLI command instead of moved blocks.
moved Blockmoved {
from = <old_address>
to = <new_address>
}
How it works:
to address, Terraform checks state for an existing object at the from addressto addressto addressIn module files: Add moved blocks anywhere in your .tf files alongside resource definitions.
Best practice: Create a dedicated moved.tf file for large refactorings to keep history clear.
Scenario: You want to give a resource a more descriptive name.
Before:
resource "aws_instance" "server" {
# ... configuration ...
}
After:
resource "aws_instance" "web_server" {
# ... configuration ...
}
moved {
from = aws_instance.server
to = aws_instance.web_server
}
What happens:
aws_instance.server is renamed to aws_instance.web_server in stateaws_instance.web_serverScenario: Renaming a resource that has multiple instances.
Before:
resource "aws_security_group" "sg" {
count = 2
# ... configuration ...
}
After:
resource "aws_security_group" "security_group" {
count = 2
# ... configuration ...
}
moved {
from = aws_security_group.sg
to = aws_security_group.security_group
}
Important: The moved block without instance keys applies to ALL instances automatically:
aws_security_group.sg[0] ā aws_security_group.security_group[0]aws_security_group.sg[1] ā aws_security_group.security_group[1]Scenario: Converting a single resource to multiple instances while preserving the original.
Before:
resource "aws_instance" "web" {
# ... configuration ...
}
After:
locals {
instances = {
small = { instance_type = "t2.micro" }
large = { instance_type = "t2.large" }
}
}
resource "aws_instance" "web" {
for_each = local.instances
instance_type = each.value.instance_type
# ... configuration ...
}
moved {
from = aws_instance.web
to = aws_instance.web["small"]
}
What happens:
aws_instance.web becomes aws_instance.web["small"]aws_instance.web["large"] as new infrastructureAlternative with count:
resource "aws_instance" "web" {
count = 3
# ... configuration ...
}
moved {
from = aws_instance.web
to = aws_instance.web[0]
}
Best practice: Always write explicit moved blocks when adding count (even though Terraform auto-maps to index 0).
Scenario: Renaming keys in a for_each resource.
Before:
resource "aws_instance" "app" {
for_each = {
small = { type = "t2.micro" }
}
# ... configuration ...
}
After:
resource "aws_instance" "app" {
for_each = {
tiny = { type = "t2.micro" }
}
# ... configuration ...
}
moved {
from = aws_instance.app["small"]
to = aws_instance.app["tiny"]
}
Scenario: Migrating from count-based to for_each-based instances.
Before:
resource "aws_instance" "app" {
count = 2
# ... configuration ...
}
After:
resource "aws_instance" "app" {
for_each = {
primary = { type = "t2.small" }
secondary = { type = "t2.micro" }
}
# ... configuration ...
}
moved {
from = aws_instance.app[0]
to = aws_instance.app["primary"]
}
moved {
from = aws_instance.app[1]
to = aws_instance.app["secondary"]
}
Scenario: Giving a module call a better name.
Before:
module "network" {
source = "./modules/vpc"
# ... configuration ...
}
After:
module "vpc" {
source = "./modules/vpc"
# ... configuration ...
}
moved {
from = module.network
to = module.vpc
}
What happens:
module.network.aws_vpc.this ā module.vpc.aws_vpc.thismodule.network.aws_subnet.public[0] ā module.vpc.aws_subnet.public[0]Scenario: Converting a single module call to multiple instances.
Before:
module "app" {
source = "./modules/service"
# ... configuration ...
}
After:
module "app" {
source = "./modules/service"
count = 3
# ... configuration ...
}
moved {
from = module.app
to = module.app[2]
}
What happens:
module.app[2]module.app[0] and module.app[1] as new infrastructureScenario: Extracting resources into a child module.
Before:
# In root module
resource "aws_instance" "web" {
# ... configuration ...
}
resource "aws_security_group" "web" {
# ... configuration ...
}
After:
# In root module
module "web_server" {
source = "./modules/web-server"
# ... configuration ...
}
moved {
from = aws_instance.web
to = module.web_server.aws_instance.web
}
moved {
from = aws_security_group.web
to = module.web_server.aws_security_group.web
}
# In ./modules/web-server/main.tf
resource "aws_instance" "web" {
# ... configuration ...
}
resource "aws_security_group" "web" {
# ... configuration ...
}
Scenario: Breaking a large module into multiple smaller, focused modules.
Before (monolithic module):
# In ./modules/app/main.tf
resource "aws_instance" "web" {
# ... configuration ...
}
resource "aws_instance" "worker" {
# ... configuration ...
}
resource "aws_db_instance" "db" {
# ... configuration ...
}
After (split into 3 modules):
Create new focused modules:
# ./modules/web/main.tf
resource "aws_instance" "web" {
# ... configuration ...
}
# ./modules/worker/main.tf
resource "aws_instance" "worker" {
# ... configuration ...
}
# ./modules/database/main.tf
resource "aws_db_instance" "db" {
# ... configuration ...
}
Convert original module to shim for backward compatibility:
# ./modules/app/main.tf (now a compatibility shim)
module "web" {
source = "../web"
# ... pass through variables ...
}
module "worker" {
source = "../worker"
# ... pass through variables ...
}
module "database" {
source = "../database"
# ... pass through variables ...
}
moved {
from = aws_instance.web
to = module.web.aws_instance.web
}
moved {
from = aws_instance.worker
to = module.worker.aws_instance.worker
}
moved {
from = aws_db_instance.db
to = module.database.aws_db_instance.db
}
What happens:
Important: This violates the "child module as closed box" principle - only do this when all modules are maintained together in the same package.
Scenario: Moving resources into a module that uses count/for_each.
Before:
resource "aws_instance" "app" {
# ... configuration ...
}
After:
module "apps" {
source = "./modules/app"
count = 3
# ... configuration ...
}
moved {
from = aws_instance.app
to = module.apps[1].aws_instance.app
}
What happens:
module.apps[1]module.apps[0] and module.apps[2]Scenario: Resource has been renamed multiple times over module evolution.
moved {
from = aws_instance.server
to = aws_instance.web_server
}
moved {
from = aws_instance.web_server
to = aws_instance.application_server
}
What happens:
aws_instance.server upgrade successfullyaws_instance.web_server upgrade successfullyaws_instance.application_serverWhy chain: Supports users upgrading from any previous version.
# Make your changes with moved blocks
terraform plan
# Verify output shows:
# - "moved" operations (not "destroy" + "create")
# - No unexpected changes
# - Correct addressing
Expected plan output:
Terraform will perform the following actions:
# aws_instance.server has moved to aws_instance.web_server
resource "aws_instance" "web_server" {
# ... (no changes) ...
}
Plan: 0 to add, 0 to change, 0 to destroy.
For large refactorings:
terraform-aws-module/
āāā main.tf
āāā variables.tf
āāā outputs.tf
āāā moved.tf # All moved blocks here
Benefits:
# Renamed to follow module naming convention (resource type in identifier is redundant)
moved {
from = aws_security_group.security_group
to = aws_security_group.web
}
# Split networking resources into dedicated module
moved {
from = aws_vpc.main
to = module.networking.aws_vpc.main
}
# === Networking refactoring (v2.0.0) ===
moved {
from = aws_vpc.vpc
to = aws_vpc.main
}
moved {
from = aws_subnet.subnet
to = aws_subnet.private
}
# === Security refactoring (v2.1.0) ===
moved {
from = aws_security_group.sg
to = module.security.aws_security_group.app
}
ā Don't remove moved blocks unless:
ā
Do keep moved blocks:
Reason: Removing moved blocks is a breaking change - users on old versions will plan to destroy infrastructure.
# Before refactoring
git tag v1.5.0
# After refactoring
git tag v2.0.0
# Document in CHANGELOG
# v2.0.0
# - BREAKING: Removed moved blocks for v1.0.0 ā v1.5.0 transitions
# - Users must upgrade to v1.5.0 first, then to v2.0.0
terraform apply
# Verify state has new addresses
terraform state list
# Check specific resource
terraform state show aws_instance.web_server
Some providers allow moving between resource types:
# Check provider documentation first!
moved {
from = aws_security_group_rule.ingress
to = aws_vpc_security_group_ingress_rule.ingress
}
Important: Not all resource type changes are supported. Consult provider docs.
Cannot do: Move from resource to data block (managed ā data source).
Not possible directly, but can structure code:
# ā This doesn't work - moved blocks don't support conditional logic
moved {
from = var.use_new_name ? aws_instance.old : aws_instance.new
to = aws_instance.final
}
ā Instead, use separate configurations or branches
Scenario: Move resource from parent to child module.
# In parent module
module "child" {
source = "./modules/child"
# ... configuration ...
}
moved {
from = aws_instance.example
to = module.child.aws_instance.example
}
Reverse (child to parent): Not directly supported by moved block. Use terraform state mv CLI command.
Before:
output "instance_id" {
value = aws_instance.server.id
}
After (with refactoring):
resource "aws_instance" "web_server" {
# ... configuration ...
}
moved {
from = aws_instance.server
to = aws_instance.web_server
}
output "instance_id" {
value = aws_instance.web_server.id
}
Maintain backward compatibility:
output "instance_id" {
value = aws_instance.web_server.id
description = "ID of the web server instance"
}
# Deprecated output for backward compatibility
output "server_id" {
value = aws_instance.web_server.id
description = "DEPRECATED: Use instance_id instead. ID of the web server instance."
}
Problem:
Error: Resource not found in state
The resource aws_instance.old was not found in the state.
Causes:
from addressSolution:
# Check what's actually in state
terraform state list
# Verify exact address
terraform state show aws_instance.old
Problem:
Error: Resource already exists
Cannot move aws_instance.old to aws_instance.new because
aws_instance.new already exists in state.
Cause: Target address already has an object in state.
Solution:
# Remove the conflicting resource first (if safe)
terraform state rm aws_instance.new
# Or move the existing resource somewhere else
terraform state mv aws_instance.new aws_instance.backup
Problem: terraform plan shows -/+ instead of moved.
Causes:
moved block has incorrect addressesmoved block is in wrong moduleSolution:
# Verify moved block syntax
terraform validate
# Check addresses exactly match
terraform state list | grep <resource>
# Review configuration changes
git diff
Problem:
Error: Circular moved block dependency
The moved blocks create a circular dependency.
Cause:
moved {
from = aws_instance.a
to = aws_instance.b
}
moved {
from = aws_instance.b
to = aws_instance.a
}
Solution: Remove circular reference or chain correctly.
Before Refactoring:
terraform state listterraform plan shows no changesterraform state pull > backup.tfstateDuring Refactoring:
moved blocks for each address changeterraform validateterraform plan and verify:After Refactoring:
terraform applyterraform state listScenario: Refactor ECS service module to follow naming conventions.
Before:
# main.tf
resource "aws_ecs_service" "service" {
name = "my-service"
# ... configuration ...
}
resource "aws_security_group" "security_group" {
name = "service-sg"
# ... configuration ...
}
resource "aws_iam_role" "task_role" {
name = "task-role"
# ... configuration ...
}
resource "aws_iam_role" "execution_role" {
name = "execution-role"
# ... configuration ...
}
After:
# main.tf
resource "aws_ecs_service" "this" {
name = "my-service"
# ... configuration ...
}
# security-group.tf
resource "aws_security_group" "this" {
name = "service-sg"
# ... configuration ...
}
# iam-role-policies.tf
resource "aws_iam_role" "task" {
name = "task-role"
# ... configuration ...
}
resource "aws_iam_role" "execution" {
name = "execution-role"
# ... configuration ...
}
# moved.tf
# Refactoring to follow naming conventions (v2.0.0)
moved {
from = aws_ecs_service.service
to = aws_ecs_service.this
}
moved {
from = aws_security_group.security_group
to = aws_security_group.this
}
moved {
from = aws_iam_role.task_role
to = aws_iam_role.task
}
moved {
from = aws_iam_role.execution_role
to = aws_iam_role.execution
}
Results:
$ terraform plan
aws_ecs_service.service has moved to aws_ecs_service.this
aws_security_group.security_group has moved to aws_security_group.this
aws_iam_role.task_role has moved to aws_iam_role.task
aws_iam_role.execution_role has moved to aws_iam_role.execution
Plan: 0 to add, 0 to change, 0 to destroy.
No infrastructure destroyed!
.github/instructions/terraform.instructions.md.github/instructions/file-structure.instructions.mdNeed to refactor? Ask:
moved block (Pattern 1)moved with instance keys (Pattern 2-5)moved with module paths (Pattern 6-8)moved to/from module (Pattern 8, 10)terraform state mv CLIAlways:
terraform plan firstmoved blocks for backward compatibility