Terraform Course
Lifecycle Rules
The lifecycle block is where you override Terraform's default behaviour for individual resources. You have used prevent_destroy and create_before_destroy. This lesson covers all six lifecycle arguments — including replace_triggered_by, precondition, and postcondition — with the production scenarios that justify each one.
This lesson covers
All six lifecycle arguments → create_before_destroy for zero downtime → replace_triggered_by for cascading replacements → precondition and postcondition for contract-based infrastructure → When each argument belongs in production
The Six Lifecycle Arguments
The lifecycle block sits inside a resource block and accepts six arguments. Each one changes a specific aspect of how Terraform manages that resource across its entire lifetime.
| Argument | What it changes | When to use it |
|---|---|---|
| create_before_destroy | Creates replacement before destroying original | Resources that must not have downtime gaps |
| prevent_destroy | Blocks any plan that would destroy this resource | Databases, VPCs, anything catastrophic to lose |
| ignore_changes | Ignores drift on specific attributes | Attributes managed by external systems |
| replace_triggered_by | Forces replace when referenced resource changes | Resources that must be recreated when a dependency changes |
| precondition | Validates inputs before the resource is created | Enforcing contracts and catching configuration errors early |
| postcondition | Validates outputs after the resource is created | Verifying the resource was created with expected properties |
Setting Up
Create a project that demonstrates all six lifecycle arguments against real AWS resources. We will build a realistic web tier — EC2 instance, security group, and S3 bucket — and apply different lifecycle rules to each.
mkdir terraform-lesson-20
cd terraform-lesson-20
touch versions.tf variables.tf main.tf outputs.tf .gitignore
Add this to versions.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
Add this to variables.tf:
variable "region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Deployment environment"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "ami_id" {
description = "AMI ID for the EC2 instance — must be valid for the target region"
type = string
default = "ami-0c55b159cbfafe1f0" # Amazon Linux 2 — us-east-1 only
}
variable "allowed_cidr_blocks" {
description = "CIDR blocks allowed inbound on port 80 — must be valid CIDR notation"
type = list(string)
default = ["0.0.0.0/0"]
}
Run terraform init and continue building main.tf below.
create_before_destroy — Zero Downtime Replacements
By default, when Terraform must replace a resource — the old one is destroyed first, then the new one is created. For a web server behind a load balancer, this creates a gap: the old server is gone and the new one is not ready yet. Traffic fails during this window.
create_before_destroy = true reverses this. The replacement is created and confirmed healthy first. Only then is the original destroyed. For resources attached to load balancers or DNS, this eliminates the downtime window entirely.
New terms:
- create_before_destroy = true — inverts the default destroy-then-create sequence for this resource. Terraform creates the new resource first, waits for it to be ready, then destroys the original. Any resources that reference this resource (via its ID or ARN) must also have
create_before_destroyset — otherwise they cannot reference a resource being destroyed while the replacement is being created. - name conflict during replacement — when
create_before_destroyis enabled, both the old and new resource exist simultaneously during the transition. If the resource has a globally unique name — like an S3 bucket or an IAM role — you must use a generated or random name. You cannot create two resources with the same name simultaneously.
Add this to main.tf:
# Data source — look up the latest Amazon Linux 2 AMI dynamically
# This replaces the hardcoded AMI ID variable with a dynamic lookup
data "aws_ami" "amazon_linux_2" {
most_recent = true
owners = ["amazon"] # Only trust AMIs published by Amazon
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"] # Amazon Linux 2 HVM x86_64
}
filter {
name = "virtualization-type"
values = ["hvm"] # Required for all modern instance types
}
}
# Security group — no name conflict risk, so create_before_destroy is safe
resource "aws_security_group" "web" {
name = "web-sg-${var.environment}"
description = "Web tier security group for ${var.environment}"
ingress {
description = "HTTP inbound"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = var.allowed_cidr_blocks # Driven by variable — validated below
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "web-sg-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
lifecycle {
create_before_destroy = true # New SG created before old is destroyed
# Security group names must be unique per VPC — if the name is unchanged
# during a replacement, AWS allows both old and new to coexist briefly
}
}
# EC2 instance — the main reason create_before_destroy matters
# During replacement, new instance must be running before old is terminated
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux_2.id # Dynamic — from data source
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "web-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
AmiName = data.aws_ami.amazon_linux_2.name # Track which AMI version is running
}
lifecycle {
create_before_destroy = true # New instance created before old is terminated — zero downtime
ignore_changes = [ami] # AMI managed by patching pipeline — do not replace on AMI updates
}
}
$ terraform apply
data.aws_ami.amazon_linux_2: Reading...
data.aws_ami.amazon_linux_2: Read complete [id=ami-0c55b159cbfafe1f0]
Plan: 2 to add, 0 to change, 0 to destroy.
Enter a value: yes
aws_security_group.web: Creating...
aws_security_group.web: Creation complete [id=sg-0abc123]
aws_instance.web: Creating...
aws_instance.web: Creation complete [id=i-0abc123def456789]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
# Now simulate a forced replacement — change the instance type
# This requires destroy + create because instance_type cannot change in-place
# on a stopped instance without a specific AWS Stop/Modify/Start cycle
$ terraform apply -var="instance_type=t3.micro"
# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
~ instance_type = "t2.micro" -> "t3.micro" # forces replacement
}
# Because create_before_destroy = true:
# 1. New t3.micro instance created first
# 2. Old t2.micro instance destroyed after
Plan: 1 to add, 0 to change, 1 to destroy.What just happened?
- The instance type change forces a replacement. EC2 does not allow changing instance_type on a running instance via Terraform — it requires stop, modify, start. Terraform treats this as a replace operation:
-/+in the plan. Withoutcreate_before_destroy, the old instance is terminated first, creating a traffic gap. With it, the new t3.micro is running before the t2.micro is terminated. - ignore_changes = [ami] prevents constant replacements. Because we use a dynamic AMI data source, the AMI ID changes whenever Amazon releases a new version. Without
ignore_changes, every plan after an AMI release would show a replace. Theignore_changesrule means the instance keeps running on its current AMI until an intentional rebuild — not every time Amazon patches something. - The security group also has create_before_destroy. If something changes the security group — description, rules — and it triggers a replace, the new SG is created before the old one is detached from the instance. This keeps the instance protected throughout the transition.
replace_triggered_by — Cascading Replacements
replace_triggered_by forces a resource to be replaced whenever a specified resource or attribute changes — even when the resource's own arguments have not changed. This is the tool for cascading replacements that Terraform cannot infer automatically.
Scenario: You have an EC2 instance and a launch configuration. The instance was created from the launch configuration. When the launch configuration changes — new AMI, new user data — the instance should be rebuilt. But the instance's arguments do not directly reference the launch configuration's content, only its ID. Without replace_triggered_by, Terraform does not know the instance needs replacing.
New terms:
- replace_triggered_by — a list of resource references or attribute references. When any item in the list changes, this resource is added to the plan as a replace even if nothing else about it changed. Accepts full resource references (
aws_security_group.web) or specific attribute references (aws_security_group.web.id). - aws_launch_template — a versioned EC2 launch configuration. Defines AMI, instance type, user data, security groups, and other instance settings. When you update a launch template, a new version is created — the old version is unchanged. EC2 Auto Scaling groups can reference the latest version automatically, but standalone EC2 instances need an explicit trigger to rebuild.
- latest_version attribute — on an
aws_launch_templateresource, this attribute tracks the most recent version number. Using it inreplace_triggered_bymeans any update to the template — which incrementslatest_version— triggers a replacement of the dependent instance.
Add this to main.tf:
# Launch template — defines the configuration for EC2 instances
# Each update creates a new version — latest_version increments
resource "aws_launch_template" "web" {
name = "web-lt-${var.environment}"
image_id = data.aws_ami.amazon_linux_2.id # AMI from data source
instance_type = var.instance_type
# User data script runs when the instance first boots
# Changes to user data require a new instance — the old one ran the old script
user_data = base64encode(<<-EOT
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "Web Server - ${var.environment}
" > /var/www/html/index.html
EOT
)
tag_specifications {
resource_type = "instance"
tags = {
Name = "web-lt-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
}
}
# EC2 instance launched from the template
# When the launch template changes, this instance must be replaced
# The instance's own arguments don't reference template content — only its ID
resource "aws_instance" "web_from_template" {
ami = data.aws_ami.amazon_linux_2.id # Must match template AMI
instance_type = var.instance_type
launch_template {
id = aws_launch_template.web.id # Reference to the template
version = aws_launch_template.web.latest_version # Always use the latest version
}
tags = {
Name = "web-template-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
lifecycle {
create_before_destroy = true # Zero downtime during forced replacement
# When the launch template's latest_version changes — meaning the template was updated —
# this instance must be replaced to run with the new template version
# Without this, the old instance keeps running on the old template indefinitely
replace_triggered_by = [
aws_launch_template.web.latest_version
]
}
}
$ terraform apply # Initial deploy
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
# Now update the launch template — change the user data
# Edit main.tf: change the echo line to a new message
# This creates a new template version without changing the instance's own arguments
$ terraform plan
# aws_launch_template.web will be updated in-place
~ resource "aws_launch_template" "web" {
~ latest_version = 1 -> (known after apply) # Will become 2
~ user_data = "..." -> "..." # New script content
}
# aws_instance.web_from_template must be replaced
# (replace_triggered_by aws_launch_template.web.latest_version)
-/+ resource "aws_instance" "web_from_template" {
# Instance arguments unchanged — but template version incremented
# replace_triggered_by forces this replacement
}
Plan: 1 to add, 1 to change, 1 to destroy.
# The template updates in-place (version 1 -> 2)
# The instance is replaced (new instance runs version 2 user data)What just happened?
- The instance replacement was triggered by a change the instance block does not own. The user_data changed inside the launch template. The instance block has no user_data argument — it delegates that to the template. Without
replace_triggered_by, Terraform would update the template and leave the old instance running with the old script. With it, the new template version triggers an automatic instance replacement. - latest_version is the correct trigger — not the template ID. The template ID never changes — it is set at creation and stays constant.
latest_versionincrements on every update. Using the ID as a trigger would never fire. Usinglatest_versionfires on every template update, which is exactly the desired behaviour. - create_before_destroy ensures zero downtime during the triggered replacement. The combination of
replace_triggered_byandcreate_before_destroymeans: template updates trigger a new instance, and that new instance is healthy before the old one is terminated. Template-driven rolling replacements with no downtime.
precondition — Catch Errors Before Apply
A precondition checks that a condition is true before Terraform creates or modifies a resource. If the condition is false, Terraform aborts the plan with a clear error message — before any infrastructure is touched. This is contract-based infrastructure: define what must be true for a resource to be safely created, and Terraform enforces the contract.
New terms:
- precondition block — nested inside a lifecycle block. Contains a
conditionexpression that must evaluate to true and anerror_messagethat prints if it is false. Multiple precondition blocks are allowed on the same resource — all must pass. - self reference — inside precondition and postcondition blocks,
selfrefers to the current resource.self.instance_typeaccesses the instance_type argument of the resource the lifecycle block belongs to. - contains() in preconditions — checks that a value is in a list of allowed values. More flexible than variable validation because it can check against computed values — the result of data sources or other resource attributes — not just the raw variable value.
Add this S3 bucket with preconditions to main.tf:
# S3 bucket with preconditions — catches configuration errors before apply
resource "aws_s3_bucket" "app_data" {
bucket = "lesson20-app-data-${var.environment}-${data.aws_caller_identity.current.account_id}"
tags = {
Name = "app-data-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
lifecycle {
prevent_destroy = true # Never allow this bucket to be destroyed via plan
precondition {
# Ensure the bucket name stays under the 63-character S3 limit
# Length of the full bucket name must not exceed 63 characters
condition = length("lesson20-app-data-${var.environment}-${data.aws_caller_identity.current.account_id}") <= 63
error_message = "Bucket name exceeds 63 characters. Shorten the name prefix or environment name."
}
precondition {
# Ensure we are not accidentally deploying to a production-scale environment
# without the correct instance sizing — enforce a configuration contract
condition = var.environment != "prod" || var.instance_type != "t2.micro"
error_message = "Production environment must not use t2.micro. Set instance_type to t3.medium or larger."
}
}
}
# Data source for account ID — used in precondition above
data "aws_caller_identity" "current" {}
# Test the environment/instance_type precondition $ terraform plan -var="environment=prod" -var="instance_type=t2.micro" Planning... data.aws_caller_identity.current: Reading... ╷ │ Error: Resource precondition failed │ │ on main.tf line 47, in resource "aws_s3_bucket" "app_data": │ 47: condition = var.environment != "prod" || var.instance_type != "t2.micro" │ ├──────────────── │ │ var.environment is "prod" │ │ var.instance_type is "t2.micro" │ │ Production environment must not use t2.micro. │ Set instance_type to t3.medium or larger. ╵ # No infrastructure was touched — precondition fired at plan time # Correct the configuration and plan succeeds: $ terraform plan -var="environment=prod" -var="instance_type=t3.medium" Plan: 1 to add, 0 to change, 0 to destroy.
What just happened?
- The precondition fired at plan time — before any API calls. Terraform evaluated both preconditions during the planning phase. When the environment/instance_type combination failed, Terraform printed the exact condition that failed, the values that caused the failure, and the custom error message — then stopped. No AWS API was called, no resource was created or modified.
- Preconditions can enforce cross-variable contracts. Variable validation in Lesson 11 can only check a single variable's value in isolation. Preconditions can check relationships between variables —
var.environment != "prod" || var.instance_type != "t2.micro"is a constraint that requires knowing both variables simultaneously. This cannot be expressed in a variable validation block. - The error message is actionable. "Set instance_type to t3.medium or larger" tells the engineer exactly what to change. Good error messages in preconditions turn configuration mistakes into self-service fixes rather than debugging sessions.
postcondition — Verify After Creation
A postcondition runs after a resource is created or updated. It validates that the resource was provisioned with the expected properties. If the condition fails, Terraform marks the apply as failed — even though the resource was created — and forces correction on the next apply.
Scenario: You create an EC2 instance and want to verify that AWS assigned it a public IP — because your configuration assumes public accessibility but AWS might not assign one depending on subnet settings. A postcondition catches this immediately rather than letting the team discover the problem hours later when the service does not respond.
# EC2 instance with postconditions — verifies the instance was created as expected
resource "aws_instance" "verified_web" {
ami = data.aws_ami.amazon_linux_2.id
instance_type = var.instance_type
associate_public_ip_address = true # We need a public IP for this instance
tags = {
Name = "verified-web-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
lifecycle {
create_before_destroy = true
postcondition {
# Verify the instance was assigned a public IP after creation
# If the subnet has map_public_ip_on_launch=false, this may not happen
# The postcondition catches this immediately rather than silently
condition = self.public_ip != ""
error_message = "Instance was not assigned a public IP. Check subnet settings — map_public_ip_on_launch may be false."
}
postcondition {
# Verify the instance type was accepted by AWS and matches what we requested
# AWS occasionally substitutes instance types during capacity constraints
condition = self.instance_type == var.instance_type
error_message = "AWS provisioned a different instance type than requested. Check regional availability."
}
}
}
$ terraform apply aws_instance.verified_web: Creating... aws_instance.verified_web: Creation complete [id=i-0abc123def456789] # Postcondition check runs after creation # Checking: self.public_ip != "" # If the subnet doesn't assign public IPs: ╷ │ Error: Resource postcondition failed │ │ on main.tf line 88, in resource "aws_instance" "verified_web": │ 88: condition = self.public_ip != "" │ ├──────────────── │ │ self.public_ip is "" │ │ Instance was not assigned a public IP. Check subnet settings — │ map_public_ip_on_launch may be false. ╵ # The instance WAS created — it exists in AWS and in state # But the apply is marked as failed # Fix the subnet settings and apply again — or add vpc_security_group_ids # to place the instance in a subnet that assigns public IPs # If postconditions pass: Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
What just happened?
- The postcondition ran after the instance was created. Unlike preconditions which run before creation, postconditions have access to the real resource attributes assigned by AWS —
self.public_ip,self.instance_type,self.id. The check happens after the creation API call completes and Terraform reads back the actual state. - The instance exists in state even though the apply failed. The creation succeeded — the instance is running in AWS. The postcondition failure marks the apply as failed but does not roll back the creation. On the next apply — after fixing the subnet to assign public IPs — the postcondition will pass and the apply will succeed. The instance is not recreated.
- self refers to the current resource's attributes after creation.
self.public_ipreads thepublic_ipattribute from the newly created resource. This is the only context whereselfis valid — inside lifecycle precondition and postcondition blocks. Outside lifecycle blocks, you must use the full resource address.
A Complete Lifecycle Block
Here is a production-grade RDS database resource combining all the lifecycle arguments that matter for a database — the resource type where lifecycle rules have the highest stakes.
# Production RDS instance — lifecycle rules tuned for zero-risk database management
resource "aws_db_instance" "primary" {
identifier = "appdb-${var.environment}"
engine = "postgres"
engine_version = "15.3"
instance_class = "db.t3.micro" # Size driven by variable — precondition checks prod sizing
allocated_storage = 20
storage_encrypted = true # Always encrypt RDS storage — no exceptions
db_name = "appdb"
username = "dbadmin"
password = var.db_password # Sensitive — supplied via TF_VAR_db_password
skip_final_snapshot = var.environment != "prod" # Create final snapshot in prod only
tags = {
Name = "appdb-${var.environment}"
Environment = var.environment
ManagedBy = "Terraform"
}
lifecycle {
prevent_destroy = true # A destroyed database is a catastrophic data loss event
create_before_destroy = true # Minimise downtime during forced replacements
# Auto Scaling may adjust allocated_storage — do not revert it
# Password rotations happen outside Terraform — do not revert them
ignore_changes = [
allocated_storage, # Managed by RDS storage autoscaling
password # Managed by secrets rotation — do not overwrite with old value
]
precondition {
# Production databases must use storage encryption — never allow unencrypted prod data
condition = var.environment != "prod" || self.storage_encrypted == true
error_message = "Production RDS instances must have storage_encrypted = true."
}
precondition {
# Minimum storage for production — 100GB floor to prevent future scaling issues
condition = var.environment != "prod" || self.allocated_storage >= 100
error_message = "Production RDS must have at least 100GB allocated storage."
}
postcondition {
# Verify the database endpoint was assigned — a sign the instance is available
condition = self.endpoint != ""
error_message = "RDS instance was created but no endpoint was assigned. Check VPC and subnet group settings."
}
}
}
What just happened?
- prevent_destroy + create_before_destroy work together.
prevent_destroystops accidental destruction via plan.create_before_destroyensures that when a forced replacement is genuinely necessary — engine version upgrade requiring a new instance — the old database stays available until the replacement is ready. Together they make the database as resilient as possible to both accidents and intentional changes. - ignore_changes protects against two external systems. RDS Storage Autoscaling silently increases
allocated_storagewhen the disk fills up. A secrets rotation system updates the password on a schedule. Withoutignore_changes, the next plan would revert both — shrinking the storage back and overwriting the current password with the old Terraform value. Both are silent disasters. - Two preconditions catch production misconfiguration before the 10-minute RDS creation wait. If someone accidentally sets
storage_encrypted = falsefor a production database, the precondition catches it at plan time — before the 10-minute wait for RDS provisioning, before any cost is incurred, before any risk of unencrypted data touching the disk.
Common Mistakes
Using create_before_destroy with a hardcoded unique name
When a resource uses create_before_destroy and has a globally unique name — like an S3 bucket or an IAM role — both the old and new resource exist simultaneously during replacement. If the name is hardcoded, the creation fails because the name is already taken. Either use a random suffix or use a name that includes a unique identifier that changes with each replacement cycle.
Using ignore_changes = all
Telling Terraform to ignore all attribute changes on a resource means Terraform stops being the source of truth for that resource — it will never plan any updates regardless of how far the resource drifts. Only ever ignore specific attributes that are genuinely managed by an external system. ignore_changes = all is almost never the right answer.
Writing preconditions that always pass
A precondition that only checks the dev environment but not prod is worse than no precondition — it gives false confidence. Write preconditions that fire in the environment where the risk is highest. The correct pattern is: var.environment != "prod" || PRODUCTION_SAFETY_CONDITION — bypass for non-prod, enforce for prod.
The lifecycle decision framework
For each resource, ask four questions. Will a replacement cause downtime? Add create_before_destroy. Could an accidental plan destroy something catastrophic? Add prevent_destroy. Is any attribute managed by something other than Terraform? Add it to ignore_changes. Should this resource rebuild when something it depends on changes even though its own arguments are unchanged? Add replace_triggered_by. Then write preconditions for the conditions that must be true before creation and postconditions for what must be true after. A complete lifecycle block answers all four questions.
Practice Questions
1. Which lifecycle argument forces a resource to be replaced when a different resource changes — even when the resource's own arguments are unchanged?
2. Inside a postcondition block, which keyword references the current resource's attributes after it has been created?
3. You want Terraform to abort the plan with a clear error if someone tries to deploy a t2.micro EC2 instance to production. Which lifecycle argument implements this check?
Quiz
1. Describe exactly what happens during a resource replacement when create_before_destroy = true.
2. What is the key difference between a precondition and a postcondition?
3. Why should allocated_storage be in ignore_changes on an RDS instance that uses storage autoscaling?
Up Next · Lesson 21
Dependencies
Terraform builds its graph from attribute references — but not every dependency can be expressed that way. Lesson 21 covers implicit and explicit dependencies, dependency cycles, and how to architect configurations that Terraform can parallelise safely.