Cloud 40 min read

AWS OpenTofu #205: Resource Lifecycle and Safe Changes

Prevent accidental downtime and data loss using lifecycle meta-arguments: create_before_destroy, prevent_destroy, ignore_changes, and replace_triggered_by.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide • Part 5 of 24

AWS + OpenTofu Level 200: Build a Reusable AWS Application Platform

Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.

In Lab 204, you mastered for_each and dynamic blocks to build scalable, dynamically configured multi-tier platforms without list index shift bugs.

Now imagine this scenario:

It is 2:00 PM on a Tuesday. You update an input variable in your OpenTofu project to change an EC2 instance’s AMI or modify an S3 bucket’s naming convention. You run tofu apply.

Suddenly, OpenTofu terminates your active production server before provisioning its replacement, plunging your API into a 4-minute blackout. Even worse, an errant pull request attempts to delete your primary production RDS database, and OpenTofu happily executes the destroy action because nothing in the code forbade it.

By default, OpenTofu operates on a simple, ruthless principle: Destroy the old resource first, then create the new one. And if a resource is removed from configuration, OpenTofu assumes you want it deleted.

In production engineering, this default behavior is unacceptable.

To operate cloud infrastructure safely, you must take control of how OpenTofu creates, updates, and destroys resources. You do this using the lifecycle meta-argument.

In this lab, you will master the four essential lifecycle directives (create_before_destroy, prevent_destroy, ignore_changes, and replace_triggered_by), learn how to decode every symbol in plan diffs, solve the dreaded name collision conflict, and complete Module 1: OpenTofu Project Engineering.


🎯 Learning Objectives

Lifecycle DirectivesPlan Diff ForensicsProduction Guardrails
• Zero-downtime replacement with create_before_destroy• Decoding +, ~, -, -/+, and +/- plan symbols• Preventing catastrophic deletions via prevent_destroy
• Eliminating perpetual drift loops with ignore_changes• Pinpointing (forces replacement) root causes• Overcoming resource name collisions with name_prefix
• Chained cascade recreation via replace_triggered_by• Pre-flight safety audits before applying• Safe state decommissioning workflows

💰 Estimated Lab Cost

ResourceConfigurationFree Tier CostStandard Account (Past Free Tier)
Security Groups with Prefixes2 AWS Security Groups$0.00$0.00 (Always 100% Free)
Encrypted S3 Storage Bucket1 S3 Bucket (Protected via prevent_destroy)$0.00$0.00 (Negligible storage)
Amazon EC2 Virtual Machine1x t3.micro On-Demand (Replacement testing)$0.00~$0.012 / hour
AWS Systems Manager (SSM)Zero-trust instance profile$0.00$0.00 (Always 100% Free)
Total Estimated CostComplete Hands-On Session (~45 min)$0.00~$0.01 (Under 2 cents)

[!NOTE] All S3 and Security Group operations are 100% free. The only potentially billable resource is the t3.micro instance, covered under the AWS Free Tier (or ~$0.012/hour on standard accounts). All resources will be cleanly destroyed at the end of the lab.


🔍 The Anatomy of an OpenTofu Plan Diff

Before diving into lifecycle rules, you must know how to read OpenTofu’s plan diff symbols with forensic precision:

SymbolOperationMeaningRisk Level
+CreateA new resource will be provisioned in AWS.🟢 Low
~Update in-placeAttributes will be modified via AWS API without destroying the resource.🟡 Medium
-DestroyThe resource will be permanently deleted from AWS.🔴 High
-/+Replace (Default)Destroy first, then create the new replacement.🚨 Severe (Downtime)
+/-Replace (Safe)Create first, then destroy the old resource (create_before_destroy).🟢 Low / Controlled
<=ReadA data source will be queried to read live AWS information.🟢 None

Why Does OpenTofu Force Replacement?

Many AWS resource attributes are immutable. Once created, the AWS API forbids updating them in-place:

  • Changing the cidr_block of an aws_subnet.
  • Changing the availability_zone of an aws_instance.
  • Changing the name of an aws_security_group or aws_s3_bucket.
  • Changing the ami of an aws_instance.

Whenever you alter an immutable attribute, OpenTofu outputs:

# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
      ~ ami = "ami-0c101f26f147fa7ab" -> "ami-0b72821e2f351e396" # forces replacement

If you do not intervene, OpenTofu executes -/+: it terminates your running server first, leaving your users stranded with connection errors until the new server boots and registers healthy.


⚡ Lifecycle Directive 1: create_before_destroy

The create_before_destroy directive reverses OpenTofu’s default replacement order:

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux_2023.id
  instance_type = "t3.micro"

  lifecycle {
    create_before_destroy = true
  }
}

With this setting, OpenTofu executes +/-:

  1. It requests AWS to provision the new EC2 instance.
  2. It waits for the new instance to reach the active state.
  3. Only after the new instance is running does it issue the terminate command to the old instance.
sequenceDiagram
    autonumber
    participant D as Default (-/+) Destroy-First
    participant S as Safe (+/-) Create-First
    participant AWS as AWS Cloud API

    rect rgb(255, 230, 230)
    Note over D,AWS: Default Behavior (-/+): Blackout Window
    D->>AWS: 1. Terminate running instance (Down!)
    D->>AWS: 2. Request new instance
    D->>AWS: 3. Wait for initialization (Users see 502/504)
    end

    rect rgb(230, 255, 230)
    Note over S,AWS: Safe Behavior (+/-): Zero Downtime
    S->>AWS: 1. Launch replacement instance
    S->>AWS: 2. Replacement reaches active state
    S->>AWS: 3. Cut over traffic / Terminate old instance
    end

The Name Collision Trap (name vs name_prefix)

There is a critical gotcha with create_before_destroy. Consider this Security Group:

resource "aws_security_group" "web" {
  name   = "web-production-sg" # STATIC NAME
  vpc_id = aws_vpc.main.id

  lifecycle {
    create_before_destroy = true
  }
}

If you modify an immutable attribute on this Security Group, OpenTofu tries to create the replacement before destroying the old one.

AWS responds with an immediate error:

Error: creating Security Group (web-production-sg): InvalidGroup.Duplicate: 
The security group 'web-production-sg' already exists for VPC 'vpc-0123456789abcdef0'.

Because security group names must be unique within a VPC, AWS rejects the creation of the new security group while the old one still exists!

The Fix: Always Use name_prefix

Instead of hardcoding name, use name_prefix:

resource "aws_security_group" "web" {
  name_prefix = "web-production-sg-"
  vpc_id      = aws_vpc.main.id

  lifecycle {
    create_before_destroy = true
  }
}

When OpenTofu replaces the resource, AWS generates unique names:

  • Existing: web-production-sg-20260916120000
  • Replacement: web-production-sg-20260916120500

Both exist simultaneously during the cutover. Once the replacement is healthy, OpenTofu deletes the old security group cleanly.


🛡️ Lifecycle Directive 2: prevent_destroy

Some resources must never be deleted by an automated IaC pipeline:

  • Production PostgreSQL databases (aws_db_instance).
  • Primary S3 buckets storing customer documents or compliance logs (aws_s3_bucket).
  • Core VPC networking hubs (aws_vpc).

The prevent_destroy directive places an ironclad safety latch on the resource:

resource "aws_s3_bucket" "audit_logs" {
  bucket = "company-immutable-audit-logs-2026"

  lifecycle {
    prevent_destroy = true
  }
}

What Happens When Destruction Is Attempted?

If an engineer runs tofu destroy or accidentally deletes the aws_s3_bucket block from main.tf, OpenTofu refuses to plan or execute:

Error: Instance cannot be destroyed

  on storage.tf line 1:
   1: resource "aws_s3_bucket" "audit_logs" {

Resource aws_s3_bucket.audit_logs has lifecycle.prevent_destroy set, but the plan calls
for this resource to be destroyed. To avoid unexpected resource loss, OpenTofu halted
execution before any action was taken.

The pipeline halts immediately. No AWS API calls are made. Your data remains safe.

[!IMPORTANT] How to Legally Destroy a Protected Resource: If you genuinely intend to decommission a resource protected by prevent_destroy:

  1. Set prevent_destroy = false in the code.
  2. Run tofu apply to update the state.
  3. Now you can safely run tofu destroy.

🙈 Lifecycle Directive 3: ignore_changes

In modern cloud environments, infrastructure is often modified by outside systems:

  1. Auto Scaling: AWS Auto Scaling Groups dynamically alter desired_capacity based on CPU load.
  2. CI/CD Deployment Pipelines: A GitHub Actions workflow pushes a new Docker image and updates an ECS task definition or container image tag without running OpenTofu.
  3. AWS Automatic Tagging: Security tooling or AWS Control Tower automatically injects governance tags (e.g., Owner, SecurityScanStatus).

If OpenTofu is unaware of this, every subsequent tofu plan will detect drift and attempt to revert the values back to what is defined in HCL:

# Unwanted Drift in ECS Service:
~ resource "aws_ecs_service" "app" {
    ~ task_definition = "arn:aws:ecs:...:task-definition/api:42" -> "arn:aws:ecs:...:task-definition/api:1"
  }

Applying this would downgrade your production application from version 42 to version 1!

The ignore_changes directive instructs OpenTofu to ignore specific attributes during plan and apply:

resource "aws_autoscaling_group" "app" {
  name             = "app-asg"
  max_size         = 10
  min_size         = 2
  desired_capacity = 2

  lifecycle {
    ignore_changes = [
      desired_capacity, # Managed dynamically by AWS Auto Scaling policies
      target_group_arns,
    ]
  }
}

Ignoring Tags Injected by External Systems

You can ignore specific tags or all tags:

lifecycle {
  # Ignore specific tags added by external security software
  ignore_changes = [
    tags["LastSecurityScan"],
    tags["VulnerabilityScore"],
  ]
}

Or ignore all changes to all attributes (use with extreme caution):

lifecycle {
  ignore_changes = all
}

🔄 Lifecycle Directive 4: replace_triggered_by

Sometimes a resource must be destroyed and recreated whenever an auxiliary configuration changes, even if the resource’s own configuration remains unchanged.

Common scenarios:

  • Re-baking or replacing an EC2 instance whenever its user_data script in an external file changes.
  • Recreating an ECS service when a secret in AWS Secrets Manager is rotated.
  • Replacing an API Gateway deployment whenever route resources change.
resource "terraform_data" "bootstrap_hash" {
  input = filemd5("${path.module}/scripts/install.sh")
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.amazon_linux_2023.id
  instance_type = "t3.micro"

  lifecycle {
    replace_triggered_by = [
      terraform_data.bootstrap_hash
    ]
  }
}

Whenever install.sh changes, terraform_data.bootstrap_hash changes its value. OpenTofu sees that aws_instance.app is triggered by this resource and forces a clean replacement.


🛠️ Hands-On Lab: Implementing and Testing Safe Lifecycles

You will build an environment that tests all four lifecycle behaviors hands-on:

  1. An S3 bucket protected with prevent_destroy.
  2. A Security Group using name_prefix and create_before_destroy.
  3. An EC2 instance with create_before_destroy and ignore_changes on dynamic tags.
  4. A deliberate destruction test to verify that prevent_destroy halts unauthorized deletions.

Step 1: Initialize Workspace

Create a dedicated lab directory:

mkdir -p ~/opentofu-labs/lab-205-safe-lifecycles
cd ~/opentofu-labs/lab-205-safe-lifecycles

Verify AWS caller identity:

aws sts get-caller-identity

Step 2: Configure Providers and Variables

Create providers.tf:

# providers.tf
terraform {
  required_version = ">= 1.8.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.50"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = "OpenTofu-Level-200"
      Lab         = "Lab-205"
      Environment = "production-sim"
      ManagedBy   = "OpenTofu"
    }
  }
}

Create variables.tf:

# variables.tf
variable "aws_region" {
  type        = string
  description = "AWS deployment region"
  default     = "us-east-1"
}

variable "instance_size" {
  type        = string
  description = "EC2 instance type"
  default     = "t3.micro"
}

variable "app_version" {
  type        = string
  description = "Application version string injected into user_data"
  default     = "1.0.0"
}

Step 3: Build Protected Storage with prevent_destroy

Create storage.tf:

# storage.tf

resource "random_id" "bucket_suffix" {
  byte_length = 4
}

resource "aws_s3_bucket" "critical_data" {
  bucket        = "safe-lifecycle-audit-vault-${random_id.bucket_suffix.hex}"
  force_destroy = false

  lifecycle {
    # Guardrail: Never allow automated pipelines to delete this bucket
    prevent_destroy = true
  }

  tags = {
    DataClassification = "MissionCritical"
  }
}

resource "aws_s3_bucket_versioning" "critical_data" {
  bucket = aws_s3_bucket.critical_data.id

  versioning_configuration {
    status = "Enabled"
  }
}

Step 4: Build Zero-Downtime Networking & Security

Create network.tf. Notice how the Security Group uses name_prefix and create_before_destroy to prevent the AWS Duplicate Name error during replacements:

# network.tf

# 1. Base VPC
resource "aws_vpc" "main" {
  cidr_block           = "10.30.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "safe-lifecycle-vpc"
  }
}

resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "safe-lifecycle-igw"
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.30.1.0/24"
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

  tags = {
    Name = "safe-lifecycle-public-subnet"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }

  tags = {
    Name = "safe-lifecycle-public-rt"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

# 2. Security Group with Zero-Downtime Replacement Lifecycle
resource "aws_security_group" "web" {
  name_prefix = "safe-web-sg-"
  description = "Web firewall with zero-downtime lifecycle replacement"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "Allow HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "Allow all outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name = "safe-lifecycle-web-sg"
  }
}

Step 5: Provision Workload with create_before_destroy and ignore_changes

Create compute.tf:

# compute.tf

data "aws_ami" "amazon_linux_2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-kernel-6.1-x86_64"]
  }
}

# IAM Role for SSM Session Manager
resource "aws_iam_role" "ssm" {
  name_prefix = "safe-ec2-ssm-"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ssm.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ssm" {
  name_prefix = "safe-ec2-profile-"
  role        = aws_iam_role.ssm.name

  lifecycle {
    create_before_destroy = true
  }
}

# Workload Instance
resource "aws_instance" "web" {
  ami                  = data.aws_ami.amazon_linux_2023.id
  instance_type        = var.instance_size
  subnet_id            = aws_subnet.public.id
  iam_instance_profile = aws_iam_instance_profile.ssm.name

  vpc_security_group_ids = [
    aws_security_group.web.id
  ]

  user_data = <<-EOF
              #!/bin/bash
              echo "App Version: ${var.app_version}" > /var/www/version.txt
              dnf install -y httpd
              systemctl start httpd
              systemctl enable httpd
              echo "<h1>Production Server - Version ${var.app_version}</h1>" > /var/www/html/index.html
              EOF

  user_data_replace_on_change = true

  lifecycle {
    # 1. Prevent downtime during replacement
    create_before_destroy = true

    # 2. Ignore tags dynamically applied by external AWS services or monitoring
    ignore_changes = [
      tags["LastInspectionTime"],
      tags["SecurityScanStatus"]
    ]
  }

  tags = {
    Name = "safe-lifecycle-web-server"
  }
}

Step 6: Outputs

Create outputs.tf:

# outputs.tf

output "s3_vault_bucket" {
  description = "ARN and name of the protected audit vault"
  value = {
    id  = aws_s3_bucket.critical_data.id
    arn = aws_s3_bucket.critical_data.arn
  }
}

output "security_group_id" {
  description = "Active Security Group ID"
  value       = aws_security_group.web.id
}

output "web_instance_id" {
  description = "Active Web EC2 Instance ID"
  value       = aws_instance.web.id
}

output "web_public_ip" {
  description = "Web instance public IPv4 address"
  value       = aws_instance.web.public_ip
}

Step 7: Deploy the Baseline Infrastructure

Initialize and apply the configuration:

tofu init
tofu apply -auto-approve

After roughly 45–60 seconds, your infrastructure is provisioned:

Apply complete! Resources: 11 added, 0 changed, 0 destroyed.

Outputs:

s3_vault_bucket = {
  "arn" = "arn:aws:s3:::safe-lifecycle-audit-vault-3f8a12bc"
  "id" = "safe-lifecycle-audit-vault-3f8a12bc"
}
security_group_id = "sg-01a2b3c4d5e6f7890"
web_instance_id = "i-0987654321fedcba0"
web_public_ip = "54.210.89.42"

Step 8: Test 1 — prevent_destroy Blocks Accidental Deletion

Now, simulate an operator error: a developer accidentally runs tofu destroy.

Execute:

tofu destroy

OpenTofu immediately aborts with an error:

Error: Instance cannot be destroyed

  on storage.tf line 6:
   6: resource "aws_s3_bucket" "critical_data" {

Resource aws_s3_bucket.critical_data has lifecycle.prevent_destroy set, but the plan
calls for this resource to be destroyed. To avoid unexpected resource loss, OpenTofu
halted execution before any action was taken.

Notice what happened: Not a single resource was touched. OpenTofu evaluated the DAG, detected that a protected resource was targeted for destruction, and halted execution immediately.


Step 9: Test 2 — create_before_destroy in Action (+/-)

Now, test zero-downtime replacement. We will change the application version, which updates user_data and forces instance replacement because user_data_replace_on_change = true.

Update variables.tf:

 variable "app_version" {
   type        = string
   description = "Application version string injected into user_data"
-  default     = "1.0.0"
+  default     = "2.0.0"
 }

Run tofu plan:

tofu plan

Examine the plan diff symbol:

OpenTofu will perform the following actions:

  # aws_instance.web must be replaced
+/- resource "aws_instance" "web" {
      ~ id                   = "i-0987654321fedcba0" -> (known after apply)
      ~ public_ip            = "54.210.89.42" -> (known after apply)
      ~ user_data            = "a1b2..." -> "c3d4..." # forces replacement
        # (other attributes unchanged)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

Notice the symbol: +/- (not -/+).

OpenTofu will create the replacement instance first, verify its creation with AWS, and only then terminate the old instance.

Apply the upgrade:

tofu apply -auto-approve

Observe the live terminal log:

aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Still creating... [20s elapsed]
aws_instance.web: Creation complete after 28s [id=i-0aabbccddeeff1122]
aws_instance.web (deposed object 0987654321fedcba0): Destroying... [id=i-0987654321fedcba0]
aws_instance.web: Still destroying... [id=i-0987654321fedcba0, 10s elapsed]
aws_instance.web: Destruction complete after 15s

Notice how OpenTofu designates the old instance as a deposed object. The new server was 100% active before the old one began terminating. Zero blackout window!


Step 10: Test 3 — ignore_changes Prevents Drift Loops

Now simulate an external security scanner injecting tags onto your live EC2 instance via the AWS CLI:

INSTANCE_ID=$(tofu output -raw web_instance_id)

aws ec2 create-tags \
  --resources "$INSTANCE_ID" \
  --tags "Key=SecurityScanStatus,Value=Passed" "Key=LastInspectionTime,Value=2026-09-17T08:00:00Z"

Verify that AWS applied the tags:

aws ec2 describe-tags --filters "Name=resource-id,Values=$INSTANCE_ID"

Now run tofu plan:

tofu plan

Output:

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and found
no differences, so no changes are needed.

Because ignore_changes was configured for tags["SecurityScanStatus"] and tags["LastInspectionTime"], OpenTofu intelligently bypassed those tags during drift calculation. Your CI/CD pipelines will not flap or try to erase external compliance tags!


🧹 Clean Decommissioning Workflow

To cleanly tear down this environment, you must follow the proper production decommissioning protocol for resources protected by prevent_destroy:

Step 1: Temporarily Disable prevent_destroy

Edit storage.tf and change prevent_destroy to false:

 resource "aws_s3_bucket" "critical_data" {
   bucket        = "safe-lifecycle-audit-vault-${random_id.bucket_suffix.hex}"
   force_destroy = true

   lifecycle {
-    prevent_destroy = true
+    prevent_destroy = false
   }
 }

Step 2: Apply the Safety Removal

tofu apply -auto-approve

Step 3: Execute Destruction

Now that the safety lock is removed, destroy all resources:

tofu destroy -auto-approve

Verify clean teardown:

Destroy complete! Resources: 11 destroyed.

📌 Module 1 Completion Summary

Congratulations! You have completed Module 1: OpenTofu Project Engineering.

flowchart LR
    L201["Lab 201: Enterprise Layout & Version Pinning"] --> L202["Lab 202: First Reusable Module (VPC)"]
    L202 --> L203["Lab 203: Module Composition & DAG"]
    L203 --> L204["Lab 204: for_each & Dynamic Blocks"]
    L204 --> L205["Lab 205: Lifecycles & Safe Changes"]

Key Rules of Production IaC Engineering:

  1. create_before_destroy = true on all stateless compute and security groups to guarantee zero-downtime rollouts.
  2. name_prefix instead of static name whenever using create_before_destroy to prevent AWS duplicate name collisions.
  3. prevent_destroy = true on all mission-critical data stores (RDS, S3, DynamoDB) to protect against rogue automation.
  4. ignore_changes on attributes dynamically modified by AWS autoscaling, CI/CD image tags, or security compliance scanners.
  5. Always read the diff symbols: +/- is safe; -/+ will cause downtime.

In Module 2: State and Environment Management (Labs 206–210), we will move away from local state files and master Remote State with S3, DynamoDB Distributed Locking, Environment Workspaces, and State Disaster Recovery.

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.