Lifecycle, Validation and Checks

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A Terraform configuration describes the infrastructure you want, and the tool works out how to get there. Most of the time the defaults are what you need. Sometimes they are not: a database must never be destroyed by accident, a container image is rolled out by a pipeline and Terraform must stop reverting it, a replacement has to be created before the old object is removed, or a module should refuse to run when an input makes no sense. Terraform answers all of these with a small family of mechanisms that look different but share one purpose, which is to let the author state rules about change and about correctness next to the code they protect.

This page describes those mechanisms as documented for Terraform 1.17 and OpenTofu 1.13. First the lifecycle block, which changes how Terraform plans and applies one object. Then the three kinds of assertion, which are input variable validation, precondition / postcondition and standalone check blocks, and the single question that separates them: when do they run, and what happens when they fail? Then the OpenTofu additions (enabled, destroy, an expression in prevent_destroy), the removed block, and what changed recently. It follows Resources and Data Sources and Variables, Outputs and Locals in the reading path, and it leads into Meta-arguments: count, for_each and depends_on, Refactoring: moved, removed and import and Testing.

The examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket). The container examples run locally with the kreuzwerker/docker provider; the covers bucket and the action example use the AWS provider and are illustrative, with placeholder names and no real accounts or credentials.

Where each mechanism fits

Terraform has six places where a rule can be attached to infrastructure, and they are easy to confuse because three of them use the words "condition" and "error_message". The following table is the map for the rest of the page.

Mechanism Declared in Runs when Failure effect Typical use

lifecycle settings (create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by)

lifecycle block of a resource

While the plan is built. They change the dependency graph and the proposed actions.

prevent_destroy turns a plan that would destroy the object into an error. The others change the plan; they do not fail.

Protect stateful objects, tolerate external edits, control replacement order and cascade replacements.

Input variable validation

variable block

Before the plan: as soon as the variable value is known. Deferred to apply only if the value is unknown.

Error. The operation stops and nothing is planned.

Reject malformed or contradictory input.

precondition

lifecycle of a resource, data or ephemeral block, and directly in an output block

During planning, before the object is created, read or saved. Deferred to apply when a referenced value is unknown.

Error. The planned actions for that object are not carried out.

State assumptions the object relies on.

postcondition

lifecycle of a resource, data or ephemeral block

After the object has been planned and applied (or the data source read).

Error. Downstream objects are not processed. Actions already taken are not undone.

Guarantee what the object provides to its dependents.

check block with assert

Top level of a module

Last step of every plan and apply, after the postconditions.

Warning. The run continues and the exit code is not changed.

Monitor the health or compliance of the whole system without blocking.

action_trigger

lifecycle of a resource

Around the create, update and destroy events of that resource.

Set by on_failure: halt (default) is an error, continue is a warning, taint is an error that also taints the resource.

Run a provider-defined side effect such as invoking a function or invalidating a cache.

The lifecycle block

Every resource block accepts a nested lifecycle block. data and ephemeral blocks accept one too, but only with the two condition types precondition and postcondition. The block customizes how Terraform handles the object during planning and apply. The settings and the block types that support them are:

Setting Meaning Supported in

create_before_destroy

Create the replacement before destroying the old object.

resource

prevent_destroy

Reject any plan that would destroy the object.

resource

ignore_changes

Do not plan updates for the listed attributes.

resource

replace_triggered_by

Replace the object when a referenced resource or attribute changes.

resource

precondition

Assert something before the object is created or read.

resource, data, ephemeral

postcondition

Assert something about the object after it is created or read.

resource, data, ephemeral

action_trigger

Run provider-defined actions on lifecycle events.

resource

destroy

Set to false to forget the object instead of destroying it (Terraform 1.16; OpenTofu 1.12).

resource, and removed

enabled

OpenTofu only (1.11). Create the object conditionally, with no count.

resource, module

Only literal values

The Terraform documentation states the rule that governs the whole block: "All lifecycle settings affect how Terraform constructs and traverses the dependency graph. As a result, only literal values can be used because the processing happens too early for arbitrary expression evaluation." In practice this means that a prevent_destroy set from a variable is rejected by Terraform. The condition blocks are the exception, because their condition expressions are ordinary expressions evaluated later, and so is the condition of an action_trigger. A second consequence is that the lifecycle rules are not stored in the state, apart from create_before_destroy, which Terraform records because it can be applied implicitly to a dependency. OpenTofu relaxes the rule for prevent_destroy and enabled, as described in OpenTofu additions: enabled and expressions in prevent_destroy.

create_before_destroy

When a change to an argument cannot be applied in place, the provider reports "forces replacement" and Terraform has to replace the object. The default order is destroy first, then create. That is the safe order for objects that must be unique, but it means a gap of downtime between the two operations. Setting create_before_destroy = true reverses the order: the replacement is created, dependents are repointed, and only then is the old object destroyed.

The setting is opt-in because many remote objects cannot exist twice at the same time. A container name, a bucket name or a DNS record are unique, so a replacement created first would collide with the original. Some resource types offer a way around this, such as a name prefix that appends a random suffix; Terraform cannot activate that for you.

The setting has two consequences that are worth knowing before you use it:

  • It propagates to dependencies. If resource A has create_before_destroy = true and depends on resource B, Terraform enables the behavior on B implicitly and stores that in the state. You cannot override it to false on B, because the opposite orders would form a dependency cycle. A Terraform change that looks local can therefore change the replacement order of a neighbour.

  • It disables destroy-time provisioners. When the resource has a provisioner that runs at destroy, setting create_before_destroy = true prevents that provisioner from running.

The following example gives the web container a unique name per revision so that the new container can start while the old one is still running.

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.7"
    }
  }
}

variable "web_image" {
  type        = string
  description = "Image of the Bookshelf web front end."
  default     = "nginx:1.29"
}

resource "docker_image" "web" {
  name = var.web_image
}

# A new suffix is generated whenever the image changes, so the old and the
# new container never share a name.
resource "random_id" "web_suffix" {
  byte_length = 3
  keepers = {
    image = docker_image.web.image_id
  }
}

resource "docker_container" "web" {
  name  = "bookshelf-web-${random_id.web_suffix.hex}"
  image = docker_image.web.image_id

  ports {
    internal = 80
    external = 8080
  }

  lifecycle {
    create_before_destroy = true
  }
}

The example above still fails on a single machine, because two containers cannot publish the same external port. The setting is only as good as the uniqueness constraints of the real object, which is why the documentation tells you to understand them for each resource type. With a cloud load balancer in front of two containers on different ports the rolling replacement works as intended. In the plan, a replacement with this setting is marked with the symbol +/- (create the replacement, then destroy) instead of -/+:

$ terraform plan
...
  # docker_container.web must be replaced
+/- resource "docker_container" "web" {
      ~ name = "bookshelf-web-3f2a9c" -> "bookshelf-web-91c07d" # forces replacement
      ...
    }

Plan: 2 to add, 0 to change, 2 to destroy.

prevent_destroy

prevent_destroy = true makes Terraform reject any plan whose actions include destroying the object, and return an error instead. The argument must be present in the configuration. It is meant for objects that are expensive or impossible to recreate, such as database instances and storage, and the documentation asks you to use it sparingly because it makes some configuration changes impossible to apply and makes terraform destroy fail once such an object exists.

Three limits are easy to miss:

  • It protects against a plan, not against the configuration. Terraform does not store the setting in the state. If you delete the whole resource block, the protection goes with it and the next apply destroys the object. To drop a resource from management without destroying it, use a removed block (see Removing from Terraform without destroying: destroy = false).

  • It also blocks replacement. A change that forces replacement includes a destroy, so it is rejected too. This is exactly the point for a database volume, but it surprises people who only thought of terraform destroy.

  • It also blocks terraform destroy. A full teardown of a development environment will fail on the first protected object. Tools that tear down environments should remove the protection through a reviewed change, not by a flag.

The Bookshelf PostgreSQL data lives in a Docker volume. Losing the volume loses the catalogue, so the volume is protected.

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

resource "docker_volume" "postgres_data" {
  name = "bookshelf-postgres-data"

  lifecycle {
    prevent_destroy = true
  }
}

resource "docker_image" "postgres" {
  name = "postgres:17"
}

resource "docker_container" "postgres" {
  name  = "bookshelf-postgres"
  image = docker_image.postgres.image_id

  env = ["POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password"]

  volumes {
    volume_name    = docker_volume.postgres_data.name
    container_path = "/var/lib/postgresql/data"
  }
}

If someone renames the volume, or runs terraform destroy, the plan fails before anything is touched. The error text below is abbreviated:

$ terraform destroy
...
Error: Instance cannot be destroyed

  on main.tf line 12:
  12: resource "docker_volume" "postgres_data" {

Resource docker_volume.postgres_data 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 option.

Since Terraform 1.7 the proposed change is also printed in the plan next to the error, which makes it easier to see why the destroy was planned. Two practical rules follow. Protect the objects, not the whole configuration: the container above can be replaced freely, only its data is precious. And pair prevent_destroy with real backups, since it does nothing against a terraform state rm, a deleted block, or a person with console access.

ignore_changes

Some attributes change outside Terraform. A pipeline updates the image tag of a service, an autoscaler adjusts a desired count, a management agent writes tags. Without a rule, every plan would propose to revert those edits. ignore_changes takes a list of attribute names that Terraform must not consider when it plans an update.

The rules:

  • The list uses relative attribute addresses. Elements of a map or list use index notation, such as tags["Name"] or ports[0].

  • The listed attributes still count when the resource is created. They are ignored only for updates.

  • Only attributes defined by the resource type can be listed. ignore_changes cannot refer to itself or to another meta-argument.

  • The special keyword all ignores every attribute. Terraform can then create and destroy the object but never proposes an update to it.

  • The list is literal: a variable cannot be used in it.

Because a replacement is decided by the same comparison, ignoring an attribute that would force replacement also suppresses that replacement.

The Bookshelf CD pipeline rolls out new catalog-api images by running docker or kubectl commands outside Terraform. Terraform should create the container with an initial image and then leave the image alone:

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

variable "catalog_api_initial_image" {
  type        = string
  description = "Image used when the container is first created. Later rollouts are done by the CD pipeline."
  default     = "bookshelf/catalog-api:1.4.0"
}

resource "docker_container" "catalog_api" {
  name  = "bookshelf-catalog-api"
  image = var.catalog_api_initial_image

  labels {
    label = "app.kubernetes.io/name"
    value = "catalog-api"
  }

  lifecycle {
    # The deployment pipeline owns the running image, not Terraform.
    ignore_changes = [image]
  }
}

Changing var.catalog_api_initial_image to 1.5.0 now produces no diff on an existing container, which is the intent. Be aware that this also hides the intentional changes made in the configuration. If the team later wants Terraform to manage the image again, they have to remove the ignore and accept the diff. ignore_changes = all is the blunt version:

# A one-time seed volume for the covers sample data: Terraform creates and
# destroys it but never updates it.
resource "docker_volume" "covers_seed" {
  name = "bookshelf-covers-seed"

  lifecycle {
    ignore_changes = all
  }
}

all is rarely a good idea on a resource that other resources depend on, since the object silently drifts away from the configuration. Prefer naming the specific attributes. For values that need to be set at creation and never touched, ignore_changes is the right tool; for values that change on every run for an unrelated reason (a timestamp, for example) fix the cause rather than ignoring it.

replace_triggered_by

replace_triggered_by forces the replacement of a resource when something it does not directly depend on changes. It takes a list of references. Each entry can be a managed resource, a resource instance or an attribute of an instance, and the trigger rule depends on what it points to:

  • A reference to a resource with several instances triggers the replacement if any instance is updated or replaced.

  • A reference to a single instance triggers the replacement if that instance is updated or replaced.

  • A reference to an attribute triggers the replacement whenever the value of that attribute changes.

With count or for_each on the resource that carries replace_triggered_by, you can use count.index or each.key to point at the matching instance of the triggering resource.

Only managed resources, instances and attributes are accepted. A local value, a variable or an input is not, because those do not have a plan action. The documented workaround is to wrap the value in a terraform_data resource, whose input follows the value, and to reference the terraform_data resource. The Bookshelf web container reads a rendered configuration file that is not tracked as an argument, so it must be replaced when the revision label changes:

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

variable "web_config_revision" {
  type        = string
  description = "Bump to force the web container to pick up a new configuration file."
  default     = "2026-10-21"
}

# terraform_data turns a plain variable into a managed resource that
# replace_triggered_by can observe.
resource "terraform_data" "web_config_revision" {
  input = var.web_config_revision
}

resource "docker_image" "web" {
  name = "nginx:1.29"
}

resource "docker_container" "web" {
  name  = "bookshelf-web"
  image = docker_image.web.image_id

  lifecycle {
    replace_triggered_by = [
      terraform_data.web_config_revision
    ]
  }
}

Setting web_config_revision to a new value changes the terraform_data instance, and the plan then shows the container as "replaced due to changes in dependency" with the reason spelled out. There is a second, smaller way to achieve the same result for a single run: terraform apply -replace=docker_container.web. Use replace_triggered_by when the relationship is permanent and should be part of the code, and -replace for a one-off.

One detail changed recently: from Terraform 1.15 the setting reports an error when an attribute in the list does not exist in the target resource, instead of ignoring it. OpenTofu 1.12 added the same validation, and also began replacing the resource when the referenced one is replaced, not only when it is updated.

Conditions and actions inside lifecycle

The remaining settings, precondition, postcondition and action_trigger, are covered in their own sections below because they are not about how Terraform changes an object but about what must be true around it and what should happen next to it.

Custom conditions

Terraform has two kinds of condition inside objects (preconditions and postconditions), one on variables, and one standalone (the check block). All of them take a condition expression that must evaluate to true, and a required error_message, and the choice between them depends on what fails and what is blocked. The error_message accepts any expression that produces a string, so it can use a template, a heredoc or format(), which is useful to convert null, lists and maps to something readable. The documentation recommends full sentences in the style of Terraform’s own errors, and multi-line messages keep their leading whitespace.

Input variable validation

A validation block in a variable block rejects bad values before anything else happens. A variable can have several validation blocks, and each failing one reports its own error. The rule uses a condition and an error_message. The variable validation was introduced in Terraform 0.13; the details of the variable block are described in Variables, Outputs and Locals.

Validation runs as soon as the value is known, which for a root module variable is at the start of plan, and for a module variable is when the caller’s argument has been evaluated. If the value is unknown (the argument depends on a resource that does not exist yet), the check is deferred to apply with the same effect. A failed validation is an error and it stops the operation: this is the strongest of the guards because nothing has been planned yet.

Two practical techniques come up all the time:

  • Use can() to turn an error raised by a function into a failed condition: can(regex("^[a-z][a-z0-9-]+$", var.x)) is false when the regular expression does not match, instead of crashing the evaluation with an unrelated error.

  • Use alltrue() over a for expression to validate every element of a collection and still produce one clear message.

Cross-variable validation

Before Terraform 1.9, a validation could mention only the variable being validated. From Terraform 1.9 a condition is a general expression: it can refer to other input variables, to locals, and to data resources in the same module. OpenTofu added the same capability in version 1.9. This removes the old workaround of putting the cross-checking precondition on an arbitrary resource just to get a place to evaluate the condition.

One restriction is evident from the design: a validation must not form a dependency cycle (a variable that depends on itself through a local, for example). A validation that refers to a value known only after apply is deferred to the apply phase. The OpenTofu page for custom conditions is inconsistent on this topic, saying in one place that conditions can refer to variables and locals and in another that a validation can refer only to the variable value; the OpenTofu 1.9 release notes state that references to variables and data are usable.

terraform {
  required_version = ">= 1.15"
}

variable "environment" {
  type        = string
  description = "Deployment environment of the Bookshelf stack."
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "The environment must be one of dev, staging or prod."
  }
}

variable "catalog_api_replicas" {
  type        = number
  description = "Number of catalog-api instances."
  default     = 1

  validation {
    condition     = var.catalog_api_replicas >= 1 && var.catalog_api_replicas <= 10
    error_message = "catalog_api_replicas must be between 1 and 10."
  }

  # Cross-variable rule (Terraform 1.9, OpenTofu 1.9): production needs at
  # least two replicas.
  validation {
    condition     = var.environment != "prod" || var.catalog_api_replicas >= 2
    error_message = "In prod, catalog_api_replicas must be at least 2, got ${var.catalog_api_replicas}."
  }
}

variable "covers_bucket_name" {
  type        = string
  description = "Name of the covers bucket."

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$", var.covers_bucket_name))
    error_message = "The bucket name must be 3 to 63 lowercase letters, digits or hyphens."
  }

  validation {
    condition     = var.environment == "prod" || startswith(var.covers_bucket_name, "${var.environment}-")
    error_message = "Outside prod, the bucket name must start with the environment, for example dev-bookshelf-covers."
  }
}

Running the configuration with an invalid combination prints every failing rule, and no plan is produced:

$ terraform plan -var environment=prod -var catalog_api_replicas=1 -var covers_bucket_name=bookshelf-covers
...
Error: Invalid value for variable

  on main.tf line 21:
  21: variable "catalog_api_replicas" {
    ├────────────────
    │ var.catalog_api_replicas is 1
    │ var.environment is "prod"

In prod, catalog_api_replicas must be at least 2, got 1.

This was checked by the validation rule at main.tf:29,3-13.

When the rule is about a relationship that involves objects outside the module, validation is the wrong tool: use a precondition on the resource that is affected.

Preconditions

A precondition is a rule that must hold before Terraform creates, updates or reads the object that carries it. It lives in the lifecycle block of a resource, data or ephemeral block, or directly in an output block (where it is not nested in lifecycle). Its job is to document an assumption and to fail clearly when the assumption is false, instead of letting the provider produce a confusing error later.

The rules of evaluation:

  • The condition can refer to any object of the same configuration scope, as long as the reference does not create a dependency cycle.

  • Terraform evaluates preconditions after count and for_each have been expanded, so each instance has its own evaluation and the condition can use each.key and count.index.

  • Terraform evaluates them before the arguments of the object, so a failing precondition takes priority over an argument evaluation error for the same object, and gives a better message.

  • When a value in the condition is not known until apply, the evaluation is deferred to the apply phase. This means a precondition on a brand-new resource may fail during apply rather than plan, while the same condition on an update is usually checked at plan time.

  • A failure is an error: the planned actions for that object are not performed.

  • self is not available in a precondition, since the object does not exist yet.

  • Do not put preconditions on both a resource and a data block that represent the same object, because that can make Terraform ignore changes to the data block.

Postconditions

A postcondition is a guarantee Terraform verifies after it has created or updated a resource, or read a data source. It can use self to refer to the attributes of its own object, which are known at that point. A failure is an error and, importantly, Terraform does not process the objects that depend on the failing one. It does not undo what it has already done: the resource exists, and the next plan will probably try to fix it again.

Postconditions are the right place to verify things the provider returns that your configuration depends on. A typical use is a data source that looks up an object and must find the right one:

  • On a data block, the postcondition runs when the data is read, and a failure stops Terraform from using that lookup. The documentation also uses this form to check a managed resource of the same configuration: put the postcondition on a data block that reads that resource (through a dependency), so that Terraform waits until the resource has finished changing before evaluating it.

  • On an ephemeral block, the condition runs each time the ephemeral resource is opened; it can verify that a secret retrieved from an external system meets the expected format without writing it to state.

  • An output block accepts only a precondition, not a postcondition.

Choosing between them

Choose by where the error is clearest. Preconditions check what the object assumes (an input, the architecture of an image); postconditions check what the object promises (a tag is present, a DNS name resolves). One postcondition on a resource with many dependents is often simpler than the same precondition repeated on each dependent. When the guarantee crosses a module boundary, each module may declare the condition so that each one verifies the other when it changes. The documentation also advises using conditions for static guardrails and check blocks for dynamic or external conditions.

The Bookshelf covers bucket must carry the Component = covers tag so that cost reports work, and its name must match the environment; a data source reads an existing KMS alias that the bucket needs.

terraform {
  required_version = ">= 1.15"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

variable "environment" {
  type    = string
  default = "dev"
}

variable "covers_bucket_name" {
  type = string
}

data "aws_kms_alias" "covers" {
  name = "alias/bookshelf-covers"

  lifecycle {
    postcondition {
      condition     = self.target_key_arn != ""
      error_message = "The alias alias/bookshelf-covers does not point at a key."
    }
  }
}

resource "aws_s3_bucket" "covers" {
  bucket = var.covers_bucket_name

  tags = {
    Component   = "covers"
    Environment = var.environment
  }

  lifecycle {
    precondition {
      condition     = var.environment == "prod" || startswith(var.covers_bucket_name, var.environment)
      error_message = "The covers bucket name ${var.covers_bucket_name} must start with ${var.environment}."
    }

    postcondition {
      condition     = lookup(self.tags, "Component", "") == "covers"
      error_message = "The covers bucket must carry the tag Component=covers."
    }
  }
}

output "covers_bucket_arn" {
  value       = aws_s3_bucket.covers.arn
  description = "ARN of the covers bucket."

  precondition {
    condition     = aws_s3_bucket.covers.tags["Component"] == "covers"
    error_message = "Refusing to publish the ARN of a bucket without the covers tag."
  }
}

A failed precondition is reported with the resource name and the message, and the object is skipped:

$ terraform plan -var covers_bucket_name=staging-covers
...
Error: Resource precondition failed

  on main.tf line 33, in resource "aws_s3_bucket" "covers":
  33:       condition     = var.environment == "prod" || startswith(var.covers_bucket_name, var.environment)
    ├────────────────
    │ var.covers_bucket_name is "staging-covers"
    │ var.environment is "dev"

The covers bucket name staging-covers must start with dev.

Conditions on an ephemeral resource

Ephemeral resources, available since Terraform 1.10, support both condition types. The following form is taken from the lifecycle reference and applies to the Bookshelf database password fetched from a parameter store:

variable "environment" {
  type        = string
  description = "Deployment environment"
}

variable "compliance_mode" {
  type        = bool
  description = "Enable compliance requirements for production"
  default     = false
}

ephemeral "aws_ssm_parameter" "database_password" {
  name = "/secrets/${var.environment}/database/password"

  lifecycle {
    precondition {
      condition     = var.environment != "prod" || var.compliance_mode == true
      error_message = "Enable compliance mode to assess production secrets."
    }

    postcondition {
      condition     = length(self.value) >= 16
      error_message = "The password from the parameter store must have at least 16 characters."
    }
  }
}

Because the value is ephemeral, it must not appear in the error_message. OpenTofu documents that an ephemeral value may be used in a condition but that referencing one in error_message hides the message and shows a warning instead (the failure is still reported), and suggests ephemeralasnull() for that case.

Check blocks

check blocks were introduced in Terraform 1.5. A check is a top-level block, independent of any resource, that holds one or more assert blocks and, optionally, one scoped data source. Its only effect on failure is a warning: it never blocks the plan or the apply. The Terraform documentation describes it as the only validation that does not block operations. That makes it the right tool for questions that you want answered continuously but must not prevent an unrelated change from being applied.

check "label" {
  data "type" "name" {
    # arguments of the data source
  }

  assert {
    condition     = data.type.name.attribute == "expected"
    error_message = "Explain what is wrong."
  }
}

Syntax and rules

  • The label follows the naming rules of resources and must be unique in the module.

  • There must be at least one assert block; there can be several. The check passes only if every assertion evaluates to true.

  • The condition can refer to variables, resources, data sources and module outputs of the current module, and to the scoped data source of the same check. error_message is required.

  • The optional nested data block takes a type, a label and the provider-specific arguments. It also accepts the depends_on and provider meta-arguments. Only its parent check can refer to it.

Scoped data sources

The scoped data source is what makes a check more than a conditional. Terraform fetches it as the final step of the plan or apply, after everything else has been applied, so it can observe the system as it is after the deployment: calling the health endpoint of an API that was just created, resolving a name, listing the members of a group. Because it is fetched last, the result cannot feed other resources in the same run.

A failing scoped data source is also softened. If the provider returns an error while reading it (the endpoint is down, the TLS certificate has expired), Terraform reports the error as a warning and continues. That avoids the well-known problem of an unavailable external service blocking a deployment, but it also means a provider error is masked: read the warnings.

depends_on on the scoped data source makes it wait for an upstream resource. The documentation shows a database data source with depends_on pointing at the instance: Terraform then displays "known after apply" until the database exists, rather than raising false warnings during the first plan.

A health check for Bookshelf

The catalog-api publishes a health endpoint. The check issues an HTTP request after the deployment and warns when the service does not answer with a 200. The http provider’s data source is the one used in the official example.

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    http = {
      source  = "hashicorp/http"
      version = "~> 3.4"
    }
  }
}

variable "catalog_api_port" {
  type    = number
  default = 8081
}

resource "docker_image" "catalog_api" {
  name = "bookshelf/catalog-api:1.4.0"
}

resource "docker_container" "catalog_api" {
  name  = "bookshelf-catalog-api"
  image = docker_image.catalog_api.image_id

  ports {
    internal = 8080
    external = var.catalog_api_port
  }
}

check "catalog_api_health" {
  data "http" "health" {
    url = "http://localhost:${var.catalog_api_port}/actuator/health"

    # Wait for the container: without this the first plan would warn.
    depends_on = [docker_container.catalog_api]
  }

  assert {
    condition     = data.http.health.status_code == 200
    error_message = "${data.http.health.url} returned status ${data.http.health.status_code}."
  }

  assert {
    condition     = can(regex("UP", data.http.health.response_body))
    error_message = "The catalog-api health endpoint does not report UP."
  }
}

When the service is down, the plan or apply still succeeds and prints a warning at the end, after the summary of the changes:

$ terraform apply
...
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Warning: Check block assertion failed

  on main.tf line 36, in check "catalog_api_health":
  36:     condition     = data.http.health.status_code == 200
    ├────────────────
    │ data.http.health.status_code is 503

http://localhost:8081/actuator/health returned status 503.

Checks versus continuous validation

Locally, a check is evaluated only when you run a plan or an apply. HCP Terraform and Terraform Enterprise can enable health assessments on a workspace; with them, the checks, the preconditions and the postconditions are re-evaluated regularly in the background, even when nobody runs Terraform, and a failure is shown as a health problem of the workspace. The example in the documentation is monitoring the validity of a certificate. Because a check can never fail a run, it cannot enforce policy by itself: for a rule that must block, use variable validation, a condition or a policy (see Policy as code).

When does each failure happen?

The following diagram shows the order of evaluation across one terraform apply. The input variable validations come first, then preconditions while the plan is built, then the postconditions after each object is applied, and finally the checks.

flowchart TD A([terraform plan or apply]) --> B[Evaluate input variable validation] B -->|failed| E1[Error: nothing is planned] B -->|passed| C[Build the plan] C --> D[Evaluate preconditions after count and for_each] D -->|failed| E2[Error: actions for that object are skipped] D -->|passed or deferred| F{prevent_destroy rejects a destroy?} F -->|yes| E3[Error: the plan is rejected] F -->|no| G[Plan is shown and approved] G --> H[Apply each object in dependency order] H --> I[Evaluate postconditions with self] I -->|failed| E4[Error: dependents are not processed, nothing is undone] I -->|passed| J[Run action_trigger actions] J --> K[Apply remaining objects] K --> L[Evaluate check blocks and scoped data sources] L -->|failed| W[Warning: the run still succeeds] L -->|passed| Z([Done]) W --> Z

Some details complete the picture:

  • Deferral. When a value used by a condition is not known until apply, the evaluation moves to the apply phase. A condition on a resource that already exists is usually evaluated at plan time, while the same condition for its initial creation may be evaluated at apply. A plan that looks clean can therefore still fail at apply for a new resource.

  • Refresh and drift. A postcondition or a precondition is evaluated again in later plans. Terraform records the results of the conditions in the state, although not the content of the conditions themselves, so changes in a condition take effect from the next run.

  • Warnings are not errors. A failed check is reported as a warning diagnostic and the run still succeeds, so a CI pipeline that must react to a failed check has to look at the warnings (for example in the JSON output) because the exit status alone will not tell it. This is a consequence of the documented behavior, so confirm it in your pipeline.

Actions and action_trigger

Terraform 1.14 introduced the top-level action block. An action is a provider-defined operation that does something outside the normal create, read, update and delete model: invoking a function, invalidating a cache, starting a job. Actions replace many uses of provisioners and null_resource. This page covers only the lifecycle side; the block itself and its relation with provisioners are described in Provisioners, actions and post-apply operations.

There are two ways to run an action. From the command line, terraform plan or terraform apply with the -invoke flag and an action address runs only that action and skips the rest of the configuration. Or in the configuration: an action_trigger block in the lifecycle of a resource declares the events that run the action.

Argument Meaning

events

Required list of lifecycle events: before_create, after_create, before_update, after_update, before_destroy and after_destroy.

condition

Optional expression that must evaluate to true for the action to run.

actions

Required ordered list of actions to run when the events and the condition match.

on_failure

Optional keyword. halt (the default) stops the apply with an error and does not taint the resource. continue logs the error and carries on. taint stops the apply, reports an error and marks the resource as tainted, so the next apply replaces it.

A provider developer implements each action, so the available actions depend on the provider. The example uses the aws_lambda_invoke action of the AWS provider to run a re-index job after the Bookshelf search function is deployed.

terraform {
  required_version = ">= 1.15"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

variable "reindex_after_update" {
  type    = bool
  default = true
}

resource "aws_lambda_function" "search_indexer" {
  function_name = "bookshelf-search-indexer"
  role          = "arn:aws:iam::111122223333:role/bookshelf-indexer" # placeholder
  handler       = "index.handler"
  runtime       = "python3.13"
  filename      = "build/indexer.zip"

  lifecycle {
    action_trigger {
      events     = [after_create, after_update]
      condition  = var.reindex_after_update
      actions    = [action.aws_lambda_invoke.reindex]
      on_failure = continue
    }
  }
}

action "aws_lambda_invoke" "reindex" {
  config {
    function_name = aws_lambda_function.search_indexer.function_name
    payload       = jsonencode({ mode = "full" })
  }
}

The events before_destroy and after_destroy and the on_failure modes arrived in Terraform 1.16; before that only the create and update events and the default failure behavior existed. In the example on_failure = continue makes a failed re-index a warning, because the deployment itself succeeded; the default halt is better when the action is a required step.

Replace, ignore, protect, remove: choosing a tool

Several of these mechanisms act on the same situation from different sides. A compact guide:

Situation Use Do not use

The object holds data that cannot be recreated

prevent_destroy, plus backups

ignore_changes = all, which does not stop a destroy

An external system edits an attribute

ignore_changes = [attribute]

ignore_changes = all on a shared object

A replacement must not cause downtime

create_before_destroy, with unique names

It on objects with unique names and no way around

A value that is not a resource should force a replacement

terraform_data with replace_triggered_by

A timestamp() in an argument

The input is wrong

Variable validation

A precondition on an unrelated resource

The provider might return something unexpected

postcondition

check, which cannot block

The system must be watched but never block

check with a scoped data source

A precondition, which blocks the run

The object leaves Terraform but must survive

removed with destroy = false

Deleting the block (destroys the object)

The object should exist only in some configurations

OpenTofu enabled, or count with 0 or 1

A dynamic block for a whole resource

Removing from Terraform without destroying: destroy = false

Deleting a resource block normally destroys the object, and prevent_destroy does not help because it goes away with the block. When the object must continue to exist and merely stop being managed (it is handed to another team or another tool, or the resource is being moved to another state), use a removed block. It has been available since Terraform 1.7, and OpenTofu 1.10 added lifecycle and provisioner support to it:

removed {
  from = docker_volume.postgres_data

  lifecycle {
    destroy = false
  }
}

In Terraform the lifecycle block of removed is required and its only supported argument is destroy. destroy = false leaves the real object alone and removes it from the state, and the plan says so ("will no longer be managed by Terraform"). destroy = true is the default behavior, and the block then serves as a record of the removal and as the carrier of destroy-time provisioners (when = destroy is mandatory, and the local-exec and remote-exec provisioners are supported). OpenTofu behaves the same way, recommends the lifecycle block and warns when it is missing; its from address cannot include an instance key. The block is described in detail in Refactoring: moved, removed and import.

destroy inside a resource lifecycle

Terraform 1.16 and OpenTofu 1.12 let you put the same rule in the lifecycle of a live resource:

resource "docker_volume" "covers_cache" {
  name = "bookshelf-covers-cache"

  lifecycle {
    destroy = false
  }
}

With it, Terraform "forgets" the object instead of destroying it whenever a destroy would have been planned: on removal of the configuration, on replacement and on destroy. OpenTofu additionally documents these points: the value must be a constant boolean, the setting is persisted in the state and so stays in effect until you change it, a removed block with destroy = true can override it for a single instance, tofu destroy exits with a non-zero status when resources were forgotten (suppress with -suppress-forget-errors, 1.12), and adding the address back later makes the tool try to create the object, which fails if it still exists. When destroy = false, prevent_destroy has no effect. The Terraform reference page documents only the one-line meaning, so for the finer points of Terraform’s behavior test against your version.

OpenTofu additions: enabled and expressions in prevent_destroy

OpenTofu keeps the language compatible with Terraform where it can, and has added lifecycle features of its own. The details of the tool are in OpenTofu; these two belong here.

enabled

Introduced in OpenTofu 1.11, enabled is a boolean in the lifecycle block of a resource or a module. It is the alternative to count = var.create ? 1 : 0 for objects that have either zero or one instance. If it is true or omitted, the object behaves normally; if it is false, the object is not created, an existing object is destroyed, and the resource evaluates to null.

The restrictions: the expression must be a boolean and cannot be unknown, null, sensitive or ephemeral; it cannot be combined with count or for_each on the same block; modules with nested provider configurations reject it (1.12). The gain is that the address has no [0]: references are aws_instance.example.id, not aws_instance.example[0].id. A disabled resource is null, so reading an attribute from it fails with "Attempt to get attribute from null value"; guard the access with try() or a null comparison (from 1.12 a comparison of a complex value to null is no longer sensitive merely because it contains a sensitive attribute, which allows it in the enabled of another block). Migrating from count needs no moved block, since OpenTofu moves the state from the [0] address on apply.

terraform {
  required_version = ">= 1.11"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

variable "enable_redis" {
  type        = bool
  description = "Whether to run the Redis cache for the Bookshelf stack."
  default     = true
}

resource "docker_image" "redis" {
  name = "redis:7"

  lifecycle {
    enabled = var.enable_redis
  }
}

resource "docker_container" "redis" {
  name  = "bookshelf-redis"
  image = docker_image.redis.image_id

  lifecycle {
    enabled = var.enable_redis
  }
}

output "redis_container_id" {
  value = docker_container.redis != null ? docker_container.redis.id : "not-created"
}

Terraform has no enabled. The equivalent in Terraform is count = var.enable_redis ? 1 : 0 with [0] in the references, as described in Meta-arguments: count, for_each and depends_on.

prevent_destroy with an expression

OpenTofu 1.12 allows the prevent_destroy argument of a managed resource to refer to other symbols of the same module, such as input variables, so one module can protect an object in production and not in a test environment. The OpenTofu reference page for lifecycle does not state this rule at the time of writing, so it is documented only in the 1.12 release notes; the Terraform documentation, as quoted above, accepts only literals.

terraform {
  required_version = ">= 1.12"
}

variable "environment" {
  type    = string
  default = "dev"
}

resource "terraform_data" "ledger" {
  input = "bookshelf-ledger"

  lifecycle {
    # OpenTofu 1.12 and later only. Terraform rejects this expression.
    prevent_destroy = var.environment == "prod"
  }
}

A module that must run in both tools should use a literal, and keep two copies of the object or a Terraform-compatible workaround (for example a separate module version per environment) rather than rely on the expression. The result of the evaluation is, as always for this setting, a plan error if a destroy is planned.

Lifecycle interactions

Combining the settings is common and mostly straightforward, but a few combinations are worth stating.

  • prevent_destroy together with create_before_destroy still fails a replacement: the old object would have to be destroyed, and that is what the guard rejects.

  • ignore_changes on an attribute that forces replacement stops the replacement from being proposed. That is useful for a name that must never change, but it means a real change in the configuration is silently ignored.

  • replace_triggered_by together with ignore_changes on the same resource are independent: the trigger replaces the object even if its own attributes are ignored.

  • A precondition on a data block that reads an object also written by a managed resource of the same configuration can read stale data. Put the check in a postcondition of the data block, which waits for the resource.

  • Provisioners and create_before_destroy: destroy-time provisioners do not run for the replaced object.

  • A terraform test run ignores prevent_destroy while cleaning up (Terraform 1.14), so protected test objects are removed; see Testing.

What changed recently

Version Change

Terraform 0.13

Input variable validation blocks.

Terraform 1.2

precondition and postcondition blocks in lifecycle, and precondition on outputs.

Terraform 1.5

Standalone check blocks with scoped data sources. Failures are warnings only.

Terraform 1.7

removed blocks (lifecycle { destroy }). A change rejected by a postcondition or by prevent_destroy is shown in the plan next to the error.

Terraform 1.9

Input variable validation can refer to other variables, locals and data resources. removed blocks can declare destroy-time provisioners.

Terraform 1.10

Ephemeral resources, with precondition and postcondition support.

Terraform 1.14

action blocks, action_trigger in lifecycle, and -invoke. terraform test ignores prevent_destroy on cleanup.

Terraform 1.15

replace_triggered_by reports an error for an attribute that does not exist.

Terraform 1.16

on_failure modes (halt, taint, continue) on action_trigger, the before_destroy and after_destroy events, actions invoked in respect of all dependencies, -invoke combined with -target, and destroy = false in a resource lifecycle.

Terraform 1.17

No change to the lifecycle block. The reference page documents all the rules above.

OpenTofu 1.9

Variable validation can refer to other objects.

OpenTofu 1.10

removed blocks can include lifecycle and provisioner. Plan summaries count "forgotten" instances.

OpenTofu 1.11

lifecycle { enabled }.

OpenTofu 1.12

prevent_destroy can refer to other symbols of the module; lifecycle { destroy } for resources; replace_triggered_by also reacts to replacement of the referenced resource and validates references; -suppress-forget-errors.

OpenTofu 1.13

Fix for tofu plan -out with a destroy = false resource that needs replacement.

For the planning and migration view of these versions see What changed and migration.

Terraform and OpenTofu differences

Topic Terraform OpenTofu

Expressions in prevent_destroy

Literal values only.

Can refer to symbols of the same module since 1.12.

Conditional resource

count = 0 or 1.

lifecycle { enabled } since 1.11, plus count.

lifecycle { destroy } on a resource

Since 1.16; the reference documents only that false removes the object from state.

Since 1.12; constant boolean, persisted in state, with -suppress-forget-errors.

removed block

lifecycle required, only destroy.

lifecycle recommended (warning if missing); provisioner supported since 1.10.

Cross-variable validation

Since 1.9.

Since 1.9.

Actions and action_trigger

Since 1.14, with on_failure since 1.16.

Not documented on the OpenTofu lifecycle pages consulted.

check blocks

Since 1.5. HCP Terraform health assessments re-run them.

Supported; a cloud backend can re-evaluate them.

Ephemeral values in error_message

Not documented on the pages consulted.

The message is hidden and a warning is shown; use ephemeralasnull().

Where the books are outdated or wrong

The reference books for this section were written before several of these features existed, and following them literally leads to avoidable problems.

  • They present variable validation as able to mention only the validated variable, which was true until Terraform 1.9. Current releases accept other variables, locals and data sources, so cross-field rules no longer need a workaround.

  • They cover precondition and postcondition, where they cover them at all, without the check block (Terraform 1.5) and without ephemeral resources.

  • They show output blocks with a condition argument; there is none. The supported way is a precondition in the output, or a check block.

  • None of them covers action_trigger, the removed block, destroy = false, or OpenTofu’s enabled.

  • They recommend null_resource with triggers to force replacements; terraform_data with replace_triggered_by is the supported pattern.

Pitfalls

  • Using prevent_destroy as the only protection. It does not survive deleting the block, a terraform state rm, or a console deletion, and it breaks terraform destroy for every environment. Combine it with backups and review.

  • Writing prevent_destroy = var.protect for Terraform. The setting takes literals only; OpenTofu 1.12 accepts it.

  • Putting ignore_changes on an attribute and then wondering why the configuration change is not applied. The change is exactly what the setting suppresses.

  • Using ignore_changes = all and expecting prevent_destroy-like safety.

  • Using create_before_destroy on a resource with a fixed name, then seeing the new object fail to be created.

  • Relying on a check to enforce a rule. It produces a warning and never blocks; make sure someone reads the warnings or use a condition.

  • Letting a postcondition block the apply of a whole stack. Prefer one clear postcondition per object that is a real guarantee, and move monitoring-style questions to check.

  • Referencing the ephemeral value in an error_message.

  • Reading a resource disabled by OpenTofu enabled = false without a try() or a null guard.

  • Forgetting that conditions are evaluated at apply when a value is unknown, so a clean plan is not a guarantee.