Chapter 3: The Core Workflow
Topic 20

Lifecycle Meta-Arguments

ConceptSafety

The lifecycle block changes how Terraform creates, replaces, and destroys a resource without changing the resource itself. It is the escape hatch for the cases where Terraform's defaults — destroy-then-create on replacement, always converge every attribute back to config — would cause an outage or fight another system that has a legitimate claim on the resource.

Four arguments do the work: create_before_destroy, prevent_destroy, ignore_changes, and replace_triggered_by. Each one is small, but each rewrites a default that is otherwise unavoidable — and using the wrong one, or scoping it too broadly, causes its own class of subtle bug. They are precision tools, not knobs to turn preemptively.

create_before_destroy

When a change forces replacement, Terraform's default is destroy-then-create: it deletes the old object, then creates the new one. For anything fronting live traffic, that ordering means downtime — the resource is gone for the duration of the recreate. create_before_destroy = true flips it: build the new one first, cut over, then destroy the old. That is how you replace a resource serving requests without a gap, provided GCP allows two to exist briefly and their names do not collide.

Replace without a traffic gap
resource "google_compute_instance_template" "processor" {
  # ... template config ...
  lifecycle {
    create_before_destroy = true
  }
}

prevent_destroy

prevent_destroy = true is a guardrail: it makes Terraform error rather than destroy the resource. Put it on anything whose deletion is catastrophic — the analytics BigQuery dataset, the Terraform state bucket, a production database — so that an accidental terraform destroy, or a change that would force a replacement, is refused instead of executed. It does not prevent changes, only deletion, and it surfaces the danger at plan time rather than after the data is gone.

The catch is that prevent_destroy also blocks a forced replacement, since replacement deletes the old object. When an unrelated change forces the dataset to be replaced, the run errors — which is the point, but it can surprise someone who did not realize their change was destructive. That surprise is the guardrail doing exactly its job.

ignore_changes

ignore_changes tells Terraform to stop reconciling specific attributes back to config. The canonical GCP use is ignore_changes = [labels] on a resource that another system stamps labels onto — a cost-allocation tool, a CI pipeline, GKE adding its own labels — so that each apply does not strip what the other system added. Without it, Terraform sees the external labels as drift and removes them on every run, and the two systems fight forever.

Scope matters enormously here. Ignoring the whole labels map stops Terraform from managing any label, including the ones you set in config — so a real label change you make never applies, silently. The right scope is the exact key the external system owns: ignore_changes = [labels["managed-by"]] ignores that one key and keeps managing the rest.

Ignore exactly the externally-managed key
resource "google_storage_bucket" "raw" {
  name     = "hatch-events-raw"
  location = "US"
  lifecycle {
    ignore_changes = [labels["managed-by"]]   # keep managing every other label
  }
}

ignore_changes for Externally-Managed Fields

Labels are the common case, but the pattern is broader. Autoscalers adjust instance counts, GCP populates server-assigned fields you never set, and external automation mutates metadata after creation. Each produces a perpetual diff — Terraform wants to undo a change made by a system that is supposed to make it. Listing exactly those attributes in ignore_changes stops the diff without abandoning management of the rest of the resource. Ignore the autoscaled field, keep managing everything else.

replace_triggered_by

replace_triggered_by forces replacement of a resource when another referenced resource or attribute changes, even though this resource's own arguments did not. The use case is when an in-place update is not enough — you want event-processor recreated whenever a referenced config object or image revision changes, not merely updated. It is the right tool only when a fresh resource is genuinely required; reach for it when a plain reference would have caused a harmless in-place update and you have forced an unnecessary destroy/create instead.

ignore_changes vs prevent_destroy

ignore_changes stops Terraform from updating the listed attributes back to config, while still managing the rest of the resource. It silences a perpetual diff on a field another system owns.

prevent_destroy stops Terraform from deleting the resource at all and errors instead. It is a deletion guardrail. The two solve unrelated problems — one quiets a field-level diff, the other refuses a destroy — and reaching for the wrong one leaves the actual problem unsolved.

Common Mistakes
  • Omitting create_before_destroy on a resource that fronts live traffic when an argument forces replacement — Terraform destroys it first and the dependents go down for the duration of the recreate.
  • Using ignore_changes = [labels] (the whole map) when only one external label needs ignoring — you also stop managing the labels you do control, so a real label change in config silently never applies.
  • Putting prevent_destroy on the analytics dataset and then being blocked mid-apply when an unrelated change forces its replacement — the run errors and you discover the forced replacement only because the guardrail caught it.
  • Setting ignore_changes on an attribute and forgetting it, then wondering why a genuine config change to that attribute never takes effect — ignored means ignored, including your intended edits.
  • Reaching for replace_triggered_by when a plain reference would have caused an in-place update anyway — you force an unnecessary destroy/create instead of letting the normal diff update in place.
  • Adding create_before_destroy to a resource whose name must be globally unique without changing the naming scheme — the new and old objects collide on the name and the create fails.
Best Practices
  • Add create_before_destroy = true to any resource whose replacement would otherwise interrupt live traffic, after confirming GCP allows two to exist briefly and names do not collide.
  • Put prevent_destroy = true on stateful, hard-to-rebuild resources — the analytics dataset, the hatch-events-raw bucket, the state bucket.
  • Scope ignore_changes to the exact attribute or label key managed externally (ignore_changes = [labels["managed-by"]]), never the whole labels map, so you keep managing the rest.
  • Use replace_triggered_by only when a referenced change genuinely requires a fresh resource and an in-place update cannot express it.
  • Comment every lifecycle block with why it exists, since each one overrides a safe default and the reason is rarely obvious to the next reader.
Comparable tools Pulumi protect, ignoreChanges, and deleteBeforeReplace map almost one-to-one Config Connector uses field managers and the state-into-spec annotation for similar control gcloud has no equivalent — lifecycle control exists only where a stateful tool tracks desired state

Knowledge Check

What does create_before_destroy = true change about a forced replacement?

  • Terraform builds the new object before destroying the old one, avoiding a gap for resources serving live traffic
  • It blocks the forced replacement entirely and raises a hard error at plan time instead of ever destroying the resource
  • It destroys and recreates the resource twice over for extra safety
  • It converts the forced replacement into an in-place attribute update

Why is ignore_changes = [labels] on the whole map usually too broad?

  • It also stops Terraform from managing the labels you set in config, so a real change silently never applies
  • It prevents the resource from ever being destroyed by any future apply
  • It forces every single label change to recreate the entire resource
  • It applies only to the labels added by GCP itself or by external tools, never to the ones you declare in config

A change forces replacement of a dataset that has prevent_destroy = true. What happens?

  • The run errors and refuses to proceed, because replacement requires deleting the protected resource
  • Terraform silently skips the protected dataset, leaves it untouched, and applies all the other planned changes
  • The dataset is replaced anyway, since prevent_destroy only blocks an explicit terraform destroy command
  • Terraform downgrades the change to an in-place update to avoid the deletion

When is replace_triggered_by the right tool rather than a plain reference?

  • When a referenced change genuinely requires a fresh resource and an in-place update cannot express it
  • Whenever you want a change in a referenced resource to update this one in place
  • When you want to stop a critical resource from ever being destroyed
  • When an external system, rather than Terraform itself, takes over managing one of the resource's attributes

You got correct