Objective
By the end of this lab, you will have:
- Created a module with a clear interface.
- Called the module from a root configuration.
- Refactored a resource inside the module (rename or move).
- Used a
movedblock to preserve state. - Verified the refactor with a plan that is empty.
The moved block is the under-used feature that allows
refactoring without recreating resources. The lab demonstrates
the pattern.
Architecture
A module with one resource, refactored across two scenarios:
+--------------------------------+
| Root module (root/) |
| ↓ |
| module "greeting" |
| ↓ |
| modules/greeting/main.tf |
| random_pet.name (initial) |
| local_file.greeting |
| ↓ |
| ~/rb-modules-lab/ |
| ├── root/ |
| │ └── main.tf |
| └── modules/ |
| └── greeting/ |
| ├── main.tf |
| └── variables.tf |
+--------------------------------+
The lab refactors the module by renaming the resource and using
a moved block to preserve state.
Requirements
- A Linux or macOS workstation with shell access.
- The Terraform CLI 1.9.x or later installed.
Scenario
You maintain a Terraform configuration with a module that creates a greeting file. You want to refactor the modules internal naming to make the module more reusable. The refactor must not destroy the existing file.
Tasks
Task 1: Create the working directory
mkdir -p ~/rb-modules-lab
cd ~/rb-modules-lab
mkdir -p root modules/greeting
Task 2: Write the modules variables
Create modules/greeting/variables.tf:
variable "name_length" {
type = number
description = "The number of words in the random name."
default = 1
}
variable "filename" {
type = string
description = "The path to the greeting file."
}
variable "content_template" {
type = string
description = "The content template, with ${name} replaced by the random name."
default = "Hello, ${name}!\n"
}
Task 3: Write the modules main configuration
Create modules/greeting/main.tf:
terraform {
required_version = ">= 1.9.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
resource "random_pet" "name" {
length = var.name_length
}
resource "local_file" "greeting" {
filename = var.filename
content = replace(var.content_template, "${name}", random_pet.name.id)
}
output "name" {
value = random_pet.name.id
description = "The generated random name."
}
output "filename" {
value = local_file.greeting.filename
description = "The path to the greeting file."
}
The module:
- Takes a filename and a content template.
- Generates a random name.
- Creates the file.
The modules outputs are the random name and the filename.
Task 4: Write the root configuration
Create root/main.tf:
terraform {
required_version = ">= 1.9.0"
}
module "greeting" {
source = "../modules/greeting"
filename = "${path.module}/greeting.txt"
}
The root module calls the module with a relative path.
Task 5: Initialise and apply
cd ~/rb-modules-lab/root
terraform init
terraform apply
Verify the file:
cat ~/rb-modules-lab/root/greeting.txt
The file was created. The state has the modules resources.
Inspect the state:
terraform state list
Expected:
module.greeting.random_pet.name
module.greeting.local_file.greeting
The state has the modules resources, addressed by the module path.
terraform state show module.greeting.local_file.greeting
The state shows the attributes.
Task 6: Verify the empty plan
terraform plan
Expected:
No changes. Your infrastructure matches the configuration.
The plan is empty.
Task 7: Scenario A — Rename the resource without a moved block
Edit modules/greeting/main.tf to rename the resource:
# Rename random_pet.name to random_pet.random_name
resource "random_pet" "random_name" {
length = var.name_length
}
# Update the reference
resource "local_file" "greeting" {
filename = var.filename
content = replace(var.content_template, "${name}", random_pet.random_name.id)
}
Run the plan from the root:
cd ~/rb-modules-lab/root
terraform plan
Question A.1: What does the plan show?
Answer
The plan shows a destroy + create:
# module.greeting.random_pet.name will be destroyed
- resource "random_pet" "name" {
...
}
# module.greeting.random_pet.random_name will be created
+ resource "random_pet" "random_name" {
...
}
Plan: 1 to add, 0 to change, 1 to destroy.The renamed resource is treated as a new resource. The old resource is destroyed. The new resource is created.
The destruction is a “soft” destroy for random_pet (the
resource is just a name in state), but the same pattern for
a real-world resource would destroy and recreate the resource.
This is the wrong outcome. The refactor should not destroy the resource.
Task 8: Add a moved block to preserve the resource
Revert the rename (so the resource is back to name):
# Back to the original
resource "random_pet" "name" {
length = var.name_length
}
resource "local_file" "greeting" {
filename = var.filename
content = replace(var.content_template, "${name}", random_pet.name.id)
}
Now apply the rename with a moved block:
# modules/greeting/main.tf
moved {
from = random_pet.name
to = random_pet.random_name
}
resource "random_pet" "random_name" {
length = var.name_length
}
Run the plan:
cd ~/rb-modules-lab/root
terraform plan
Question A.2: What does the plan show?
Answer
The plan is empty:
No changes. Your infrastructure matches the configuration.The moved block tells Terraform that the resource at the
from address has been moved to the to address. Terraform
updates the state to reflect the new address. The state is
preserved.
Verify the state:
terraform state list
Expected:
module.greeting.random_pet.random_name
module.greeting.local_file.greeting
The state has the new address.
terraform state show module.greeting.random_pet.random_name
The state shows the attributes. The attributes are preserved from the original resource.
Task 9: Scenario B — Move a resource into a submodule
The module currently has the random_pet and local_file
resources. The next refactor moves the local_file into a
sub-module.
Create modules/greeting/file/main.tf:
terraform {
required_version = ">= 1.9.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
variable "filename" {
type = string
description = "The path to the file."
}
variable "content" {
type = string
description = "The content of the file."
}
resource "local_file" "file" {
filename = var.filename
content = var.content
}
output "filename" {
value = local_file.file.filename
description = "The path to the file."
}
Edit modules/greeting/main.tf to delegate to the submodule:
terraform {
required_version = ">= 1.9.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
moved {
from = random_pet.name
to = random_pet.random_name
}
resource "random_pet" "random_name" {
length = var.name_length
}
module "file" {
source = "./file"
filename = var.filename
content = replace(var.content_template, "${name}", random_pet.random_name.id)
}
output "name" {
value = random_pet.random_name.id
description = "The generated random name."
}
output "filename" {
value = module.file.filename
description = "The path to the greeting file."
}
Run the plan:
cd ~/rb-modules-lab/root
terraform plan
Question B.1: What does the plan show?
Answer
The plan shows the resource moved into the submodule:
# module.greeting.local_file.greeting has moved to module.greeting.module.file.local_file.fileThe plan is otherwise empty. The state is updated to reflect the new address.
Verify the state:
terraform state list
Expected:
module.greeting.module.file.local_file.file
module.greeting.random_pet.random_name
The state has the new addresses.
Task 10: Verify file integrity
cat ~/rb-modules-lab/root/greeting.txt
The file content is unchanged. The refactor preserved the real-world resource.
Validation
The lab is successful if:
- The module was created with a clear interface.
- The root module called the module.
- The rename was done without destroying the resource.
- The state was preserved through the refactor.
- The plan was empty after each refactor.
Expected Outcome
At the end of the lab:
+---------------------------------+
| ~/rb-modules-lab/ |
| .terraform/ |
| .terraform.lock.hcl |
| root/ |
| │ greeting.txt |
| │ main.tf |
| └── modules/ |
| └── greeting/ |
| ├── main.tf |
| ├── variables.tf |
| └── file/ |
| └── main.tf |
+---------------------------------+
The greeting.txt file was created by the module. The state
has the modules resources, addressed by the module path.
Cleanup
cd ~/rb-modules-lab/root
terraform destroy
rm -rf .terraform .terraform.lock.hcl terraform.tfstate*
What You Learned
You learned the refactoring workflow:
- Always use a
movedblock when renaming a resource or moving it within a module hierarchy. - A refactor without a
movedblock destroys the resource. The plan will show the destroy + create. - A
movedblock tells Terraform that the resource at thefromaddress has been moved to thetoaddress. The state is updated; the plan is empty. - Verify the plan is empty after every refactor. A non-empty plan is evidence of a problem.
- A wrong
movedblock can corrupt state. Verify thefromandtoaddresses carefully.