Terraform lifecycle

Terraform State Management Best Practices Infrastructure as Code

What is Terraform lifecycle?

A lifecycle block is a nested configuration block you add inside a resource (and, for a couple of arguments, a data or ephemeral block) to override Terraform's default behavior for how it creates, updates, or destroys that one resource. It doesn't change what the resource is; it changes the Terraform operations that run when Terraform decides what to do with it. Like count and for_each, lifecycle is one of Terraform's meta arguments: a setting available on any resource block regardless of which provider defines it.

Six arguments are in that one block:

  1. Create_before_destroy
  2. Prevent_destroy
  3. Ignore_changes
  4. Replace_triggered_by
  5. the precondition/postcondition pair
  6. action_trigger

They're not isolated. All six Terraform lifecycle rules share a single underlying restriction that explains why half of them behave the way they do.

This page covers the whole block: the syntax, the restriction that governs every argument in it, a worked example that combines two of them on the same resource, the mistakes people actually hit, and the nearest alternative when the block itself isn't the right tool.

ignore_changes gets a full page of its own elsewhere on this site (linked below), so it's covered here only briefly, as one argument among six.

Basic Terraform lifecycle syntax

The lifecycle block sits inside a resource block alongside its other arguments, and it can hold more than one lifecycle argument at once:

resource "aws_db_instance" "orders" {
identifier = "orders-production"
engine = "postgres"
engine_version = "16.3"
instance_class = "db.r6g.large"
allocated_storage = 200
lifecycle {
prevent_destroy = true
}
}

It is a resource block like any other, with a lifecycle {} block added wherever it's convenient to read, usually last. prevent_destroy here does exactly what it says: it exists to prevent Terraform from running any plan that would destroy aws_db_instance.orders, which is the standard guard for a production database or any other stateful resource where losing it isn't an option.

What Terraform lifecycle actually does

By default, Terraform determines what to do with a resource by comparing your configuration against what's already in the state file.

Depending on what changed, it takes one of four actions: it creates a new resource that exists in configuration but not yet in state, destroys an individual resource that's in state but gone from configuration, updates one in place when the provider allows it, or destroys and recreates one when an attribute change can't be applied without a full replacement, forcing instance replacement rather than a quiet update.

A lifecycle block doesn't add a fifth action. It changes the order, the permissibility, or the attribute scope of those same four, for one resource at a time.

Here's the restriction that ties the whole block together, a restriction that is relatively uncommon among IaC tools: Terraform evaluates lifecycle settings while it's still constructing the dependency graph, well before it starts evaluating the expressions inside a resource body, which means every value inside a lifecycle block has to be a literal, known before Terraform touches a single input variable or local value.

You can't drive prevent_destroy off a variable, you can't make ignore_changes conditional on an environment, and replace_triggered_by accepts only resource addresses, not plain values like locals or variables, because those don't have planned actions of their own for Terraform to watch.

If you genuinely need a plain value to behave like a resource for this purpose, the terraform_data resource gives local values and input variables a resource-like lifecycle that replace_triggered_by can actually reference.

There's also an important second detail: except for create_before_destroy, Terraform doesn't record lifecycle rules to state at all. The rule only exists for as long as the resource block defining it exists in your configuration. Delete the block, and Terraform destroys the actual infrastructure on the next apply, prevent_destroy or not, because by then there's no lifecycle argument left for it to evaluate.

With that in mind, here's what each of the six arguments actually controls:

  1. create_before_destroy reverses the default order on a forced replacement: Terraform provisions the new object first and tears down the old one only once the replacement succeeds, which avoids potential downtime from a window with no object at all. The trade-off is that the resource's unique identifier briefly exists twice, so anything that must stay globally unique (e.g., a bucket name or a DNS record) needs a naming scheme that tolerates that overlap.
  2. prevent_destroy makes a destroy plan against that resource fail outright, with Terraform raising an error rather than touching the real infrastructure, which is exactly the "instance cannot be destroyed" message shown below.
  3. ignore_changes tells Terraform to ignore changes to specified attributes on a given resource once it's created, useful when something outside Terraform legitimately owns a field going forward. The full syntax, the all keyword, and the conditional and module-level limitations that trip people up get their own dedicated treatment, linked in the related terms section below.
  4. replace_triggered_by forces a resource to be replaced whenever a referenced resource, instance, or instance attribute changes, even when nothing about the resource's own configuration moved.
  5. precondition and postcondition blocks assert something has to be true before or after Terraform acts on a resource, data source, or ephemeral resource, letting you check how data sources operate against real values rather than just resources, and fail the run with a custom message when the check doesn't hold.
  6. action_trigger, added in recent Terraform versions, invokes a provider action (think a webhook or a notification) on a lifecycle event like after_create or before_destroy, without reaching for an external script or a provisioner.

Terraform lifecycle in action

Say a checkout service runs as a Lambda function, and its environment variables are encrypted with a dedicated KMS key rather than the account default:

resource "aws_kms_key" "checkout_webhook_env" {
description = "Encrypts environment variables for the checkout-webhook Lambda function"
deletion_window_in_days = 30
lifecycle {
prevent_destroy = true
}
}
resource "aws_lambda_function" "checkout_webhook" {
function_name = "checkout-webhook"
role = aws_iam_role.checkout_webhook.arn
handler = "index.handler"
runtime = "nodejs20.x"
filename = "checkout_webhook.zip"
kms_key_arn = aws_kms_key.checkout_webhook_env.arn
environment {
variables = {
STRIPE_WEBHOOK_SECRET = var.stripe_webhook_secret
}
}
lifecycle {
create_before_destroy = true
}
}

Two different arguments, two different jobs on two different resources.

Encryption keys are exactly the kind of critical resources prevent_destroy was built for: the KMS key gets it because destroying it would make every secret it ever encrypted permanently unreadable, a cost that has nothing to do with convenience and everything to do with physics.

There's no "undo" on a deleted key. Run terraform destroy against this configuration, or let a -targeted plan reach that resource, and Terraform stops with an error instead of completing the destruction:

│ Error: Instance cannot be destroyed
│
│ on main.tf line 1:
│ 1: resource "aws_kms_key" "checkout_webhook_env" {
│
│ Resource aws_kms_key.checkout_webhook_env has lifecycle.prevent_destroy
│ set, but the plan calls for this resource to be destroyed. To avoid this
│ error and continue with the plan, either disable
│ lifecycle.prevent_destroy or reduce the scope of the plan using the
│ -target flag.

The Lambda function gets create_before_destroy instead, to avoid downtime when a runtime bump or a handler change forces replacement; Stripe's webhooks shouldn't have nowhere to land just because the function needed a rebuild.

Terraform logs Creation complete for the replacement function before it ever logs Destruction complete for the old one, so the function handling checkout events is never actually gone mid-deploy. Combine the two in one terraform plan, and the plan output reflects both decisions at once: a destroy that Terraform refuses to even propose for the key, and a create-then-destroy sequence for the function instead of the default destroy-then-create.

Common mistakes with Terraform lifecycle

prevent_destroy guards against a plan that destroys the resource, not against removing the resource from configuration entirely. Delete the aws_db_instance.orders block above from your Terraform file (not just the lifecycle block inside it) and Terraform will still destroy the actual database on the next apply, because a resource that no longer appears in configuration has nothing left to attach prevent_destroy to, and Terraform's resource actions for that address default back to a plain destroy.

If the goal is to walk away from Terraform management without touching the real infrastructure, terraform state rm is the right tool, not prevent_destroy.

The literal-values restriction catches people constantly, and it shows up in a specific, recognizable way: someone tries prevent_destroy = var.is_production or an ignore_changes list built from a for expression, and Terraform rejects it, because by the time lifecycle rules are evaluated, variables and locals haven't been resolved yet.

The workaround is almost always the same: define two versions of the resource (one with the lifecycle rule, one without) and pick between them with count, rather than trying to make the rule itself conditional.

replace_triggered_by throws a related but distinct error when someone aims it at a local value or an input variable instead of a resource address, since those don't carry planned actions Terraform can watch for a change.

The fix, as covered above, is wrapping the plain value in a terraform_data resource so it has something resource-shaped for replace_triggered_by to reference.

And during a state migration, specifically moving resources between modules or state files with moved blocks, a prevent_destroy left in place can block the migration outright.

A move that Terraform internally represents as a destroy-and-recreate (rather than a clean in-place move) will hit the same "Instance cannot be destroyed" error as any other forced replacement, which means migrations against protected resources usually need the guard temporarily lifted, the move applied, and the guard restored afterward, in that order.

The nearest alternatives to Terraform lifecycle

The lifecycle block bundles several distinct controls, so the nearest alternative depends on which one you're trying to replace.

For prevent_destroy specifically, most major cloud providers now offer the same protection natively on the resource itself: an RDS instance's deletion_protection argument, or a GCP resource's own deletion_protection flag, stop a deletion at the provider API layer regardless of which tool is issuing the request.

It's a meaningfully different guarantee: prevent_destroy only stops Terraform, while provider-native deletion protection also blocks a console click or a stray AWS CLI call that bypasses Terraform entirely.

For replace_triggered_by, terraform apply -replace (the modern successor to the retired terraform taint command) is the manual equivalent: a one-off, explicit instruction to replace a specific resource address on the next apply, without encoding the trigger permanently into configuration.

It's the better fit when a replacement is a one-time operational decision rather than a standing rule tied to another resource's lifecycle.

Stategraph's perspective on Terraform lifecycle

lifecycle rules are enforced within the boundary of the state file Terraform is currently evaluating, and only for the duration of that run. That is correct, deliberate Terraform behavior, not a gap.

The friction shows up downstream of it: in a shared state with several teams planning against the same resources, lifting prevent_destroy on one critical resource for a legitimate, planned replacement means someone has to know what else touches that resource first, and ideally shouldn't have to block every other team's plan while they figure it out.

Graph-aware execution is built around exactly that friction, not retrofitted to paper over it. Stategraph's resource-level locking means lifting a guard on one resource to run a deliberate, reviewed replacement doesn't require locking the whole state against every other team's work while that happens, and its blast radius queries let whoever's making the change see what actually depends on the protected resource before they touch it, rather than finding out from a sprawling plan output after the fact.

Neither of those changes what lifecycle itself does; they change how much coordination overhead a team pays every time a protected resource genuinely needs to move.

If untangling that coordination overhead across more than a handful of protected resources sounds familiar, try Stategraph free and see what a queryable dependency graph adds once lifecycle guards are only part of the picture.