Lifecycle Management #
Creating resources with Terraform is easy. What’s more challenging is managing them after creation — especially when they need to be changed, moved, or deleted without disrupting running infrastructure. Terraform provides several mechanisms for this: the lifecycle block that controls resource behavior, moved blocks for refactoring without destruction, and removed blocks for controlled decommissioning. Understanding these tools lets you make major configuration changes safely.
flowchart TD
A["lifecycle block"] --> B["prevent_destroy"]
A --> C["create_before_destroy"]
A --> D["ignore_changes"]
E["moved block"] --> F["Rename\nwithout destroy"]
G["removed block"] --> H["Decommission\ncontrolled"]
style A fill:#8b5cf6,stroke:#6d28d9,color:#fff
style B fill:#3b82f6,stroke:#1e40af,color:#fff
style C fill:#3b82f6,stroke:#1e40af,color:#fff
style D fill:#3b82f6,stroke:#1e40af,color:#fff
style E fill:#10b981,stroke:#059669,color:#fff
style F fill:#10b981,stroke:#059669,color:#fff
style G fill:#f59e0b,stroke:#d97706,color:#fff
style H fill:#f59e0b,stroke:#d97706,color:#fffLifecycle Blocks: Controlling Resource Behavior #
# The four most useful lifecycle options
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
lifecycle {
# 1. create_before_destroy
# Create the new resource BEFORE deleting the old one
# Useful for resources that can't have any gap
create_before_destroy = true
# 2. prevent_destroy
# Terraform errors if anyone tries to destroy this resource
# Protection for critical resources (production databases, etc.)
prevent_destroy = true
# 3. ignore_changes
# Ignore changes to specific attributes
# Useful if the attribute is changed outside Terraform
ignore_changes = [
tags["LastModified"],
user_data,
]
# 4. replace_triggered_by (Terraform 1.2+)
# Trigger replacement when another resource changes
replace_triggered_by = [
aws_launch_template.web.id
]
}
}
create_before_destroy: Zero-Downtime Replacement #
# Without create_before_destroy (the default):
# 1. Delete the old resource
# 2. Create the new resource
# → There's a gap where no resource is running (downtime)
# ANTI-PATTERN: A resource needing high availability without create_before_destroy
resource "aws_lb_target_group" "app" {
name = "production-tg"
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id
# If the TG name needs to change → destroy first, then create → downtime
}
# CORRECT: With create_before_destroy and name_prefix
resource "aws_lb_target_group" "app" {
name_prefix = "app-" # AWS generates a unique suffix
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id
lifecycle {
create_before_destroy = true
# 1. Create a new TG with a new name
# 2. Move the listener to the new TG
# 3. Delete the old TG
# → No downtime
}
}
moved Blocks: Refactoring Without Destruction #
A moved block is the right way to change a resource’s address in state without deleting and recreating the resource in the cloud. Useful during configuration refactoring — renaming resources, moving them into modules, or reorganizing structure.
# Scenario 1: Renaming a resource
# Before: resource "aws_instance" "server"
# After: resource "aws_instance" "web_server"
moved {
from = aws_instance.server
to = aws_instance.web_server
}
# Terraform will update the state (rename the address)
# without deleting and recreating the instance on AWS
# Scenario 2: Moving a resource into a module
# Before: resource "aws_vpc" "main" in the root module
# After: moved into module "networking"
moved {
from = aws_vpc.main
to = module.networking.aws_vpc.main
}
# terraform plan will show:
# # aws_vpc.main has moved to module.networking.aws_vpc.main
# → No resources are deleted or recreated
# Scenario 3: Moving from count to for_each
# Before: resource "aws_subnet" "public" count = 3
# After: resource "aws_subnet" "public" for_each
moved {
from = aws_subnet.public[0]
to = aws_subnet.public["ap-southeast-1a"]
}
moved {
from = aws_subnet.public[1]
to = aws_subnet.public["ap-southeast-1b"]
}
moved {
from = aws_subnet.public[2]
to = aws_subnet.public["ap-southeast-1c"]
}
A moved block can be removed after all environments have been applied and the state has been updated. But it’s better to leave it for a few sprints before removal so rarely applied environments also get the update.removed Blocks: Controlled Decommissioning #
A removed block (Terraform 1.7+) lets you remove a resource from the configuration and state without deleting the actual resource in the cloud — useful for “releasing” a resource from Terraform’s control without destroying it.
# Scenario: A legacy database is no longer managed by Terraform
# but shouldn't be deleted from the cloud (data still exists)
removed {
from = aws_db_instance.legacy
lifecycle {
destroy = false # Don't delete from the cloud, only remove from state
}
}
# After apply:
# - The resource is removed from Terraform state
# - The resource REMAINS on AWS
# - Terraform no longer manages this resource
# Different from destroy = true (for resources that truly need deletion):
removed {
from = aws_instance.deprecated_worker
lifecycle {
destroy = true # Remove from state AND from the cloud
}
}
Managing Breaking Changes in Modules #
When a module used in many places needs a breaking change, there’s a safer way than “just change it and break all callers”.
# Strategy: Backward compatibility with a deprecation period
# modules/networking/main.tf — add an alias for the renamed input
variable "vpc_cidr_block" {
description = "CIDR block for the VPC"
type = string
}
# The old deprecated variable — still present for backward compatibility
variable "cidr_block" {
description = "DEPRECATED: Use vpc_cidr_block. Will be removed in v3.0"
type = string
default = null
}
locals {
# Use the new one if present, fall back to the old one
vpc_cidr = coalesce(var.vpc_cidr_block, var.cidr_block)
}
resource "aws_vpc" "main" {
cidr_block = local.vpc_cidr
}
A SAFE DEPRECATION TIMELINE:
v2.0 — The new variable is added, the old variable still exists
v2.1 — A warning is shown if the old variable is still used
v3.0 — The old variable is removed (mentioned in the CHANGELOG and README)
Callers have time to migrate between major versions.
When State Surgery Is Needed #
There are situations where manual state manipulation is necessary — this must be a last resort, not the default.
# State surgery — use with extreme care
# Move a resource between states (if a moved block isn't enough)
terraform state mv \
-state=old/terraform.tfstate \
-state-out=new/terraform.tfstate \
aws_vpc.main \
aws_vpc.main
# Remove a resource from state without deleting it from the cloud
terraform state rm aws_instance.orphaned
# Import an existing resource into state
terraform import aws_instance.existing i-0abcdef1234567890
# ALWAYS back up before state surgery
terraform state pull > backup-$(date +%Y%m%d-%H%M%S).tfstate
# Verify after state surgery
terraform plan # Should be "No changes" if the state is correct
flowchart LR
A["Old resource\nname/path"] -->|"moved block"| B["New resource\nname/path"]
B --> C["State updated\nNo destroy"]
style A fill:#f59e0b,stroke:#d97706,color:#fff
style B fill:#10b981,stroke:#059669,color:#fff
style C fill:#3b82f6,stroke:#1e40af,color:#fffLifecycle Hooks in CI/CD #
Good lifecycle management integrates Terraform with the CI/CD pipeline.
# GitHub Actions: Lifecycle-aware pipeline
name: Terraform Lifecycle
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Drift detection every Monday at 6am
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: terraform init
- run: terraform plan -out=tfplan
apply:
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- run: terraform apply tfplan
drift:
if: github.event_name == 'schedule'
steps:
- run: terraform plan -refresh-only -detailed-exitcode
Resource Lifecycle Monitoring #
# Monitor resources that prevent destruction
grep -r "prevent_destroy = true" --include="*.tf"
# Check resources with lifecycle rules
terraform state list | while read resource; do
terraform state show "$resource" 2>/dev/null | grep -A2 lifecycle
done
# Audit: which resources need prevent_destroy?
# - Databases (production data)
# - S3 buckets (data loss risk)
# - KMS keys (encrypted data becomes inaccessible)
# - DNS records (service disruption)
Resource Drift Detection #
# Drift = the difference between the Terraform state and the actual cloud condition
# Happens when:
# 1. Someone changes a resource manually in the console/CLI
# 2. Auto-scaling changes the instance count
# 3. An external process changes tags/attributes
# Detect drift:
terraform plan -refresh-only
# Shows the difference between state and real infrastructure
# Or: only refresh the state without applying
terraform apply -refresh-only
# Scheduled drift detection in CI/CD:
# Run daily/hourly, alert if there's drift
flowchart TD
A["Scheduled Job"] --> B["terraform plan\n-refresh-only"]
B --> C{"Drift\ndetected?"}
C -->|"Yes"| D["Alert the team"]
C -->|"No"| E["Log: OK ✅"]
D --> F["Review changes"]
F --> G{"Expected?"}
G -->|"Yes"| H["Update state\n(apply -refresh-only)"]
G -->|"No"| I["Revert manual\nchanges"]Migration Planning #
# Before making breaking changes:
# 1. Analyze the impact
terraform plan 2>&1 | grep -E "must be replaced|will be destroyed"
# 2. Use -target for staged migrations
terraform apply -target=aws_instance.new_web
# Verify the new resource works
terraform apply -target=aws_instance.old_web
# Remove the old resource
# 3. Use moved blocks (Terraform 1.1+)
# Refactor without destroy/create
# moved block: rename a resource without destruction
moved {
from = aws_instance.web
to = aws_instance.application_server
}
# moved block: restructure a module
moved {
from = module.networking.aws_subnet.public
to = module.networking.aws_subnet.dmz
}
Summary #
create_before_destroymatters for high-availability resources — make sure it’s on load balancers, target groups, and resources that can’t have a gap when replaced.prevent_destroyas a last-line protection for critical resources — Terraform errors if any plan would delete this resource.movedblocks are the right way to refactor configurations — rename resources, move them into modules, or switch fromcounttofor_eachwithout destruction.removedblocks for controlled decommissioning — you can choose whether the resource is deleted from the cloud or just released from Terraform’s control.- Breaking module changes need a deprecation period — add the new variable while keeping the old one, give callers time to migrate before removing the old variable.
- State surgery (
terraform state mv/rm) is a last resort — always back up state first, verify withterraform planafterward.