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.
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 Directives | Plan Diff Forensics | Production 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
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| Security Groups with Prefixes | 2 AWS Security Groups | $0.00 | $0.00 (Always 100% Free) |
| Encrypted S3 Storage Bucket | 1 S3 Bucket (Protected via prevent_destroy) | $0.00 | $0.00 (Negligible storage) |
| Amazon EC2 Virtual Machine | 1x 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 Cost | Complete 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.microinstance, 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:
| Symbol | Operation | Meaning | Risk Level |
|---|---|---|---|
+ | Create | A new resource will be provisioned in AWS. | 🟢 Low |
~ | Update in-place | Attributes will be modified via AWS API without destroying the resource. | 🟡 Medium |
- | Destroy | The 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 |
<= | Read | A 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_blockof anaws_subnet. - Changing the
availability_zoneof anaws_instance. - Changing the
nameof anaws_security_grouporaws_s3_bucket. - Changing the
amiof anaws_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 +/-:
- It requests AWS to provision the new EC2 instance.
- It waits for the new instance to reach the active state.
- 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:
- Set
prevent_destroy = falsein the code.- Run
tofu applyto update the state.- Now you can safely run
tofu destroy.
🙈 Lifecycle Directive 3: ignore_changes
In modern cloud environments, infrastructure is often modified by outside systems:
- Auto Scaling: AWS Auto Scaling Groups dynamically alter
desired_capacitybased on CPU load. - 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.
- 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_datascript 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:
- An S3 bucket protected with
prevent_destroy. - A Security Group using
name_prefixandcreate_before_destroy. - An EC2 instance with
create_before_destroyandignore_changeson dynamic tags. - A deliberate destruction test to verify that
prevent_destroyhalts 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:
create_before_destroy = trueon all stateless compute and security groups to guarantee zero-downtime rollouts.name_prefixinstead of staticnamewhenever usingcreate_before_destroyto prevent AWS duplicate name collisions.prevent_destroy = trueon all mission-critical data stores (RDS, S3, DynamoDB) to protect against rogue automation.ignore_changeson attributes dynamically modified by AWS autoscaling, CI/CD image tags, or security compliance scanners.- 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.