lifecycle rules: Terraform & IaC lessons from production
We've all been there: a Terraform apply runs and destroys a database we meant to keep, or an update triggers a full resource recreation during business hours. Lifecycle rules are your guardrails—they give you fine-grained control over how Terraform creates, updates, and destroys resources. In this article, we'll walk through the four essential lifecycle meta-arguments we've learned to rely on across production workloads.
Understanding lifecycle in Terraform
Every resource in Terraform has a lifecycle. By default, Terraform will create resources, update them in place when their arguments change, and destroy them when they're removed from configuration. This default behavior works for many cases, but production systems often need stricter controls.
The lifecycle block lets you override this default behavior. You can prevent accidental deletions, ensure zero-downtime updates, ignore drift from external tools, and force replacements based on dependencies. Without these rules, we've watched deployments take down services or leave orphaned resources behind.
prevent_destroy for critical resources
Databases, load balancers, and other stateful resources shouldn't disappear because someone removed a line from Terraform code. The prevent_destroy meta-argument stops Terraform from destroying a resource, even if you remove it from your configuration.
Here's a real scenario: your RDS database is defined in Terraform, but a junior engineer refactors the module structure and accidentally removes the database block. Without prevent_destroy, a terraform apply would delete years of data. With it, Terraform errors out and forces an explicit decision.
resource "aws_db_instance" "main" {
identifier = "production-db"
engine = "postgres"
instance_class = "db.r6i.xlarge"
allocated_storage = 100
lifecycle {
prevent_destroy = true
}
}
When you run terraform plan with this resource removed, you'll see:
Error: Instance cannot be destroyed
on db.tf line 1, in resource "aws_db_instance" "main":
1: resource "aws_db_instance" "main" {
Resource has lifecycle.prevent_destroy set, but the plan includes a destroy action.
To actually destroy the resource later, you must update the code to set prevent_destroy = false, go through code review, and then apply. This extra friction is intentional—it forces visibility on potentially destructive changes.
When to use it: Databases, state buckets, DNS zones, encryption keys, and any resource whose loss would be a critical incident.
create_before_destroy for zero downtime
Some resources must be replaced (not updated in place), but tearing down the old one before the new one is ready causes downtime. Autoscaling groups, load balancer target groups, and network interfaces often need this pattern.
By default, Terraform destroys the old resource first, then creates the new one. With create_before_destroy = true, it flips the order: create the new resource, let it stabilize, then destroy the old one. Your traffic can flow to the new resource while the old one shuts down gracefully.
Consider an autoscaling group with a new launch template. If you update the launch template in place and force a replacement, Terraform will start terminating instances before new ones are ready. This causes a traffic dip. With create_before_destroy, new instances launch and join the load balancer first.
resource "aws_autoscaling_group" "app" {
name = "app-asg"
vpc_zone_identifier = aws_subnet.private[*].id
max_size = 6
min_size = 3
desired_capacity = 3
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
lifecycle {
create_before_destroy = true
}
}
When the launch template changes, Terraform will:
- Create new instances with the new template
- Wait for them to register with the load balancer
- Terminate old instances
This costs a bit extra during the transition window (you're running both old and new instances), but production systems typically accept that trade-off for zero downtime.
When to use it: Autoscaling groups, security groups (when they must be replaced), target groups, and any stateless resource that can handle brief periods of duplication.
ignore_changes for externally managed fields
Real production systems rarely live in Terraform alone. Databases get patched by AWS, load balancer configurations drift due to manual fixes, or monitoring tools add tags. When external systems modify a resource after Terraform creates it, you have a choice: let Terraform drift detection alert you, or explicitly tell Terraform to ignore those fields.
The ignore_changes meta-argument tells Terraform to not track changes to specific attributes. This keeps your Terraform code clean when you know a field will be managed externally.
We've used this extensively with RDS instances. AWS manages minor version patches automatically, and we want Terraform to ignore those updates. Otherwise, every plan shows a spurious change to engine_version.
resource "aws_db_instance" "main" {
identifier = "production-db"
engine = "postgres"
engine_version = "15.2"
instance_class = "db.r6i.xlarge"
lifecycle {
ignore_changes = [
engine_version,
tags["Environment"]
]
}
}
Now, if AWS patches the engine to 15.3, or if a monitoring tool adds a tag, terraform plan won't show those as changes. You're explicitly accepting that those fields are managed elsewhere.
You can also ignore all changes with ignore_changes = all, though this is rare—it's useful only when you're using Terraform purely as a read-only documentation layer for a resource managed entirely outside Terraform.
When to use it: Fields that AWS manages (like RDS minor versions), tags added by automation tools, IP addresses assigned by DHCP, or any attribute you know will drift externally.
replace_triggered_by for forced replacement
Sometimes a change to one resource should force a replacement of another resource, even if Terraform wouldn't normally detect that dependency. For instance, changing a security group rule might require redeploying all EC2 instances that use that group. Or updating a Docker image tag should force a new ECS task definition.
replace_triggered_by creates an explicit trigger: when the referenced resource changes, destroy and recreate the dependent resource.
Imagine you manage an ECS service with a task definition. The task definition contains the Docker image tag, which lives in a separate data source or computed value. If that tag changes, you want new tasks to deploy. Without replace_triggered_by, Terraform might not see the task definition as changed (if the tag is computed), so no rollout happens.
resource "aws_ecs_task_definition" "app" {
family = "my-app"
network_mode = "awsvpc"
container_definitions = jsonencode([{
name = "app"
image = local.docker_image_uri
# ... other config
}])
lifecycle {
replace_triggered_by = [local.docker_image_uri]
}
}
resource "aws_ecs_service" "app" {
name = "my-app-service"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 3
lifecycle {
replace_triggered_by = [aws_ecs_task_definition.app.arn]
}
}
If local.docker_image_uri changes (say, from myapp:v1 to myapp:v2), the task definition is replaced, and because the service has replace_triggered_by pointing to the task definition ARN, the service will redeploy with new tasks.
When to use it: When resource changes should propagate as replacements downstream; common with container images, configuration files stored as data, or when you need to guarantee a rollout without relying on attribute change detection.
Common mistakes
Overusing prevent_destroy: Teams sometimes add prevent_destroy = true to every resource "just in case." This defeats the purpose—it should be reserved for truly critical resources. A development database doesn't need this protection. Review your lifecycle rules regularly and remove them when they're no longer necessary.
Forgetting create_before_destroy with health checks: create_before_destroy requires that new resources can coexist with old ones briefly. If your load balancer or autoscaling group has aggressive health check settings, instances might be marked unhealthy during the transition. Test this during a maintenance window before relying on it.
Using ignore_changes to hide problems: If you're ignoring changes because Terraform conflicts with another tool, that's a sign to reconsider your IaC strategy. Sometimes the right answer is to move management entirely to Terraform. Ignore_changes is a tool for legitimate drift (like AWS patches), not a band-aid for architectural conflicts.
Chaining replace_triggered_by without understanding the flow: If A triggers B and B triggers C, a change to A will cascade through multiple replacements. This might be what you want, but it's easy to accidentally cause a full stack redeployment. Draw out your trigger dependencies and test in a staging environment.
Key takeaways
- Use
prevent_destroyon stateful resources (databases, buckets, DNS) to block accidental deletion and force explicit code review. create_before_destroyenables zero-downtime rolling updates by creating new resources before tearing down old ones; essential for autoscaling groups and load balancer infrastructure.ignore_changestells Terraform to stop tracking external modifications on specific fields, reducing noise in plans without abandoning infrastructure-as-code discipline.replace_triggered_bycreates explicit dependency edges that force replacements when upstream resources change, useful for container deployments and cascading updates.- Test lifecycle rules in staging before production; they change resource behavior in ways that can surprise teams if not well understood.
- Document why each lifecycle rule exists—future teammates (and future you) will thank you when reviewing these rules months later.






Comments
Loading comments…