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:#fff

Lifecycle 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:#fff

Lifecycle 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_destroy matters for high-availability resources — make sure it’s on load balancers, target groups, and resources that can’t have a gap when replaced.
  • prevent_destroy as a last-line protection for critical resources — Terraform errors if any plan would delete this resource.
  • moved blocks are the right way to refactor configurations — rename resources, move them into modules, or switch from count to for_each without destruction.
  • removed blocks 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 with terraform plan afterward.

← Previous: Monorepo vs Multirepo   Next: Scale Terraform →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact