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 |
|---|---|---|---|---|
|
|
While the plan is built. They change the dependency graph and the proposed actions. |
|
Protect stateful objects, tolerate external edits, control replacement order and cascade replacements. |
Input variable |
|
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. |
|
|
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. |
|
|
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. |
|
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. |
|
|
Around the create, update and destroy events of that resource. |
Set by |
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 the replacement before destroying the old object. |
|
|
Reject any plan that would destroy the object. |
|
|
Do not plan updates for the listed attributes. |
|
|
Replace the object when a referenced resource or attribute changes. |
|
|
Assert something before the object is created or read. |
|
|
Assert something about the object after it is created or read. |
|
|
Run provider-defined actions on lifecycle events. |
|
|
Set to |
|
|
OpenTofu only (1.11). Create the object conditionally, with no |
|
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
Ahascreate_before_destroy = trueand depends on resourceB, Terraform enables the behavior onBimplicitly and stores that in the state. You cannot override it tofalseonB, 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 = trueprevents 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.
Source: Terraform Docs — terraform plan.
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
resourceblock, the protection goes with it and the next apply destroys the object. To drop a resource from management without destroying it, use aremovedblock (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"]orports[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_changescannot refer to itself or to another meta-argument. -
The special keyword
allignores 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
]
}
}
Source: Terraform Docs — The lifecycle meta-argument, replace_triggered_by and Terraform Docs — The terraform_data managed resource type.
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.
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))isfalsewhen the regular expression does not match, instead of crashing the evaluation with an unrelated error. -
Use
alltrue()over aforexpression 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.
Source: Terraform Docs — Custom Conditions.
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
countandfor_eachhave been expanded, so each instance has its own evaluation and the condition can useeach.keyandcount.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
applyrather thanplan, 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.
-
selfis not available in a precondition, since the object does not exist yet. -
Do not put preconditions on both a
resourceand adatablock that represent the same object, because that can make Terraform ignore changes to thedatablock.
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
datablock, 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 adatablock that reads that resource (through a dependency), so that Terraform waits until the resource has finished changing before evaluating it. -
On an
ephemeralblock, 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
outputblock accepts only aprecondition, 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.
Source: Terraform Docs — Custom Conditions.
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
assertblock; there can be several. The check passes only if every assertion evaluates totrue. -
The
conditioncan refer to variables, resources, data sources and module outputs of the current module, and to the scoped data source of the same check.error_messageis required. -
The optional nested
datablock takes a type, a label and the provider-specific arguments. It also accepts thedepends_onandprovidermeta-arguments. Only its parentcheckcan 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.
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 |
|---|---|
|
Required list of lifecycle events: |
|
Optional expression that must evaluate to |
|
Required ordered list of actions to run when the events and the condition match. |
|
Optional keyword. |
Source: Terraform Docs — The lifecycle meta-argument, action_trigger and Terraform Docs — Invoke actions.
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" })
}
}
Source: Terraform Docs — Invoke actions.
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 |
|
|
An external system edits an attribute |
|
|
A replacement must not cause downtime |
|
It on objects with unique names and no way around |
A value that is not a resource should force a replacement |
|
A |
The input is wrong |
Variable |
A precondition on an unrelated resource |
The provider might return something unexpected |
|
|
The system must be watched but never block |
|
A precondition, which blocks the run |
The object leaves Terraform but must survive |
|
Deleting the block (destroys the object) |
The object should exist only in some configurations |
OpenTofu |
A |
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
}
}
Source: Terraform Docs — The lifecycle meta-argument, destroy and OpenTofu Docs — Resource behavior.
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"
}
}
Source: OpenTofu v1.12 CHANGELOG.
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_destroytogether withcreate_before_destroystill fails a replacement: the old object would have to be destroyed, and that is what the guard rejects. -
ignore_changeson 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_bytogether withignore_changeson the same resource are independent: the trigger replaces the object even if its own attributes are ignored. -
A
preconditionon adatablock that reads an object also written by a managed resource of the same configuration can read stale data. Put the check in apostconditionof 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 testrun ignoresprevent_destroywhile cleaning up (Terraform 1.14), so protected test objects are removed; see Testing.
What changed recently
| Version | Change |
|---|---|
Terraform 0.13 |
Input variable |
Terraform 1.2 |
|
Terraform 1.5 |
Standalone |
Terraform 1.7 |
|
Terraform 1.9 |
Input variable validation can refer to other variables, locals and data resources. |
Terraform 1.10 |
Ephemeral resources, with |
Terraform 1.14 |
|
Terraform 1.15 |
|
Terraform 1.16 |
|
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 |
|
OpenTofu 1.11 |
|
OpenTofu 1.12 |
|
OpenTofu 1.13 |
Fix for |
Source: Terraform v1.16 CHANGELOG, Terraform v1.15 CHANGELOG, Terraform v1.14 CHANGELOG, Terraform v1.9 CHANGELOG, Terraform v1.7 CHANGELOG, OpenTofu v1.12 CHANGELOG and Terraform Docs — Custom Conditions.
For the planning and migration view of these versions see What changed and migration.
Terraform and OpenTofu differences
| Topic | Terraform | OpenTofu |
|---|---|---|
Expressions in |
Literal values only. |
Can refer to symbols of the same module since 1.12. |
Conditional resource |
|
|
|
Since 1.16; the reference documents only that |
Since 1.12; constant boolean, persisted in state, with |
|
|
|
Cross-variable validation |
Since 1.9. |
Since 1.9. |
Actions and |
Since 1.14, with |
Not documented on the OpenTofu lifecycle pages consulted. |
|
Since 1.5. HCP Terraform health assessments re-run them. |
Supported; a cloud backend can re-evaluate them. |
Ephemeral values in |
Not documented on the pages consulted. |
The message is hidden and a warning is shown; use |
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
validationas 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
preconditionandpostcondition, where they cover them at all, without thecheckblock (Terraform 1.5) and without ephemeral resources. -
They show
outputblocks with aconditionargument; there is none. The supported way is apreconditionin the output, or acheckblock. -
None of them covers
action_trigger, theremovedblock,destroy = false, or OpenTofu’senabled. -
They recommend
null_resourcewithtriggersto force replacements;terraform_datawithreplace_triggered_byis the supported pattern.
Pitfalls
-
Using
prevent_destroyas the only protection. It does not survive deleting the block, aterraform state rm, or a console deletion, and it breaksterraform destroyfor every environment. Combine it with backups and review. -
Writing
prevent_destroy = var.protectfor Terraform. The setting takes literals only; OpenTofu 1.12 accepts it. -
Putting
ignore_changeson an attribute and then wondering why the configuration change is not applied. The change is exactly what the setting suppresses. -
Using
ignore_changes = alland expectingprevent_destroy-like safety. -
Using
create_before_destroyon a resource with a fixed name, then seeing the new object fail to be created. -
Relying on a
checkto 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 = falsewithout atry()or a null guard. -
Forgetting that conditions are evaluated at apply when a value is unknown, so a clean plan is not a guarantee.