lifecycle Meta-Arguments: Guard Your Infrastructure

lifecycle Meta-Arguments: Guard Your Infrastructure
The Default Behavior Will Surprise You

When Terraform needs to replace a resource — because a changed attribute is marked as ForceNew by the provider — the default behavior is: destroy the old resource first, then create the new one. During that gap, the resource doesn’t exist.

For a web server in a pool behind a load balancer, that might be survivable. For a production database, a DNS zone, or the S3 bucket storing your Terraform state — that gap is an outage.

The lifecycle block gives you four arguments to control how Terraform handles creates, destroys, and updates. Each one solves a specific operational problem, and understanding when to use each is the difference between an infrastructure engineer and someone who just writes HCL.

create_before_destroy — Zero-Downtime Replacements
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"

  lifecycle {
    create_before_destroy = true
  }
}

Without this: destroy old → create new (brief period with no instance)
With this: create new → verify it’s healthy → destroy old (always at least one running)

This is essential for:
– Instances in load balancer target groups — the new instance should be registered and healthy before the old one is removed
– Launch template updates — create the new version before retiring the old one
– Any resource where continuous availability matters

The catch: the new and old resource must be able to coexist simultaneously. For resources with globally unique names (S3 buckets, some DNS records), both can’t exist at the same time. In those cases, you need a naming strategy that avoids conflicts — often a random suffix:

resource "random_id" "suffix" {
  byte_length = 4
  keepers     = { ami_id = var.ami_id }
}

resource "aws_instance" "web" {
  ami  = var.ami_id
  tags = { Name = "web-${random_id.suffix.hex}" }

  lifecycle {
    create_before_destroy = true
  }
}

An important subtlety: create_before_destroy propagates through the dependency graph. If your instance depends on a security group, and the instance gets replaced with create_before_destroy, Terraform may need to replace the security group the same way — creating the new one before destroying the old one. Watch the plan output carefully; the cascade can be wider than expected.

prevent_destroy — The Safety Net for Critical Resources
resource "aws_db_instance" "prod" {
  identifier     = "prod-database"
  engine         = "postgres"
  instance_class = "db.t3.medium"
  ...

  lifecycle {
    prevent_destroy = true
  }
}

Any operation that would destroy this resource — terraform destroy, removing it from config, or a forced replacement — will fail with a clear error:

Error: Instance cannot be destroyed
│ Resource aws_db_instance.prod has lifecycle.prevent_destroy set,
│ but the plan calls for this resource to be destroyed.

Apply prevent_destroy to:
– Production databases — The most obvious use. If someone accidentally runs terraform destroy or removes a resource block, this catches it before data is lost.
– S3 buckets with important data — Deleting a bucket deletes all objects inside it. Irreversible.
– State backend resources — The S3 bucket or GCS object that stores your Terraform state. Deleting this creates a chicken-and-egg problem that’s extremely painful to recover from.
– DNS zones — Deleting a Route53 hosted zone removes all DNS records. Services go offline globally.

To actually destroy a prevent_destroy resource, you must: (1) remove prevent_destroy = true from the config, (2) apply that change, (3) then destroy. The two-step process is intentional — it forces a deliberate, reviewable decision.

An important limitation: prevent_destroy only blocks Terraform from destroying the resource. Someone with console access can still delete it manually. For comprehensive protection, use cloud-native delete protection in addition — RDS deletion protection, S3 MFA delete, etc. Think of prevent_destroy as the Terraform-level safety and cloud-native protection as the infrastructure-level safety.

ignore_changes — Coexisting with External Modifications

Some resource attributes are legitimately modified outside of Terraform — by auto-scaling controllers, deployment tools, security scanners, or other automation. Without ignore_changes, Terraform detects these modifications as drift and plans to revert them.

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"

  tags = {
    Name        = "web-server"
    Environment = var.environment
  }

  lifecycle {
    ignore_changes = [
      tags["LastDeployedBy"],    # Updated by CI/CD after deployment
      tags["LastScanDate"],      # Updated by security scanning tool
      ami,                        # AMI updates managed by separate process
    ]
  }
}

Real-world scenarios where ignore_changes is appropriate:
– Tags applied by external tools — Security scanners, cost allocation tools, and compliance systems often add tags. You don’t want Terraform removing them on the next apply.
– Auto Scaling Group desired count — The ASG controller adjusts the count based on load. Terraform sets the initial count but shouldn’t fight the controller’s adjustments.
– AMIs managed by a separate pipeline — A golden image pipeline updates AMIs through a different process. Terraform should use the initial AMI but not revert updates.
– Passwords rotated by secrets managers — The initial password is set by Terraform, subsequent rotations happen externally.

The nuclear option — ignore_changes = all — tells Terraform to ignore ALL changes after initial creation. The resource becomes effectively read-only to Terraform. Use this sparingly and only for resources you’re importing into Terraform for state tracking without full lifecycle management.

replace_triggered_by — Forcing Replacement on Upstream Changes

Sometimes a resource needs to be recreated when a dependency changes, even though its own config hasn’t technically changed:

resource "aws_launch_template" "app" {
  name_prefix   = "app-"
  image_id      = var.ami_id
  instance_type = "t3.medium"
}

resource "aws_autoscaling_group" "app" {
  ...
  launch_template {
    id      = aws_launch_template.app.id
    version = aws_launch_template.app.latest_version
  }

  lifecycle {
    replace_triggered_by = [aws_launch_template.app.id]
  }
}

When the launch template changes (new AMI, new instance type), the ASG is replaced — even though the ASG block itself hasn’t changed. This triggers a rolling replacement of instances in the ASG.

Other use cases:
– Forcing certificate-dependent resources to update when a new SSL cert is provisioned
– Rebuilding caches or indices when the underlying data source changes
– Triggering re-deployment of container services when the image changes

Design Scenario: Production Database Configuration
resource "aws_db_instance" "main" {
  identifier        = "prod-db"
  engine            = "mysql"
  engine_version    = var.db_version
  instance_class    = "db.t3.medium"
  allocated_storage = 100

  lifecycle {
    prevent_destroy       = true   # Never accidentally delete the database
    create_before_destroy = true   # Blue/green style replacement for engine upgrades
    ignore_changes = [
      password,                    # Rotated by secrets manager, not Terraform
    ]
  }
}

This combination provides defense in depth: accidental deletions are blocked, engine version upgrades happen with minimum downtime (new instance is created before old one is removed), and password rotations by the secrets manager don’t generate unnecessary Terraform diffs. Each lifecycle argument solves a specific operational risk.

Key Takeaways

lifecycle blocks cannot contain dynamic expressions or references to other resources. All values must be literals. ignore_changes = [var.some_attribute] won’t work — the list must contain actual attribute names, not variable references.

prevent_destroy is the most important lifecycle argument for production infrastructure. Add it to every database, every state bucket, every critical data store. The minor inconvenience of a two-step destroy process is nothing compared to the cost of accidental deletion. One day, someone will run terraform destroy against the wrong workspace by mistake. prevent_destroy will catch it.

ignore_changes should be used deliberately, not as a band-aid. If you’re ignoring changes to avoid dealing with drift, that’s a design problem. If you’re ignoring changes because an external system legitimately modifies the attribute, that’s a valid use case. The distinction matters — one hides problems, the other acknowledges shared ownership.

create_before_destroy is about availability, not correctness. Both approaches produce the same end result — a new resource. The difference is whether there’s a gap between destroy and create. For anything behind a load balancer or serving production traffic, the gap matters.