Skip to content
SRE & DevOpsDeep Dive Published Updated 9 min readViews unavailable

Terraform Conditions: Put Each Invariant at the Right Evaluation Boundary

Choose validations, preconditions, postconditions, and non-blocking checks by what they know and whether failure should stop a plan or apply.

Terraform has several ways to express assumptions about infrastructure, and they do not all fail at the same time or with the same consequence. Input variable validation checks caller-provided values. Preconditions guard assumptions before Terraform acts on a resource, data source, or output. Postconditions enforce guarantees about a result. Check blocks observe broader or changing behavior without blocking the operation when their assertion fails.

Choosing the right mechanism is more than syntax. A blocking condition can prevent an unsafe change, but if it is attached at the wrong point it may produce a confusing error or run only after dependent work has started. A non-blocking check can provide useful health evidence, but it is not a safety gate. Design each condition around the value it can actually observe and the point at which the operation must stop.

Start with the guarantee and its timing

Before adding a condition, write down three things: which value is being evaluated, when Terraform can know it, and whether failure must prevent further action. A naming rule for an input variable is known before planning and belongs in variable validation. A requirement that a selected image supports the chosen machine architecture is an assumption about a resource dependency and belongs in a precondition. A promise that a data source returned an approved owner or that a created resource meets a configuration guarantee belongs in a postcondition. An endpoint health probe is generally an observation that should be reported without pretending Terraform can repair it.

The evaluation phase is affected by unknown values. If the condition depends on an attribute available only after the provider creates a resource, Terraform cannot decide it during the initial plan. It defers the check until the value becomes known during apply. A plan that does not show a failed condition therefore does not prove every postcondition has already been evaluated.

Validate caller inputs early

Variable validation is best for local constraints on module inputs: a string must follow a naming format, a port must be within range, or an enumerated value must be one of a supported set. It improves the message at the boundary where a caller supplied the value and stops planning when the expression is false.

variable "service_port" {
  type        = number
  description = "Listening port for the service."

  validation {
    condition     = var.service_port >= 1 && var.service_port <= 65535
    error_message = "service_port must be an integer between 1 and 65535."
  }
}

Validation should state only rules that are reliably decidable from the input. Do not encode a remote API lookup in a variable validation and expect Terraform to treat it like an infrastructure preflight. Do not make a condition stricter than the service contract without documenting why; an organization-specific convention is valid, but describe it as a policy choice rather than as a universal platform limit.

For structured inputs, validate related fields together when their relationship is part of the module interface. For example, if callers supply a primary and replica region, reject equal values only when the architecture actually requires geographic separation. Give error messages a corrective direction and avoid echoing secrets or values that might be sensitive.

Preconditions protect assumptions before an operation

A precondition belongs inside the lifecycle of a resource, data source, or output. It expresses something Terraform must establish before proceeding with that object. A common case is a property of a data source result that must be true before a resource can consume it.

resource "aws_instance" "application" {
  ami           = data.aws_ami.application.id
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = data.aws_ami.application.architecture == "x86_64"
      error_message = "The selected AMI must support the x86_64 application image."
    }
  }
}

If the AMI architecture is known while Terraform plans, the precondition can reject the plan before the instance is created. If a precondition depends on a value that only becomes known after an apply-time operation, Terraform may defer evaluation. Be explicit about the dependency that provides the value and understand that a deferred failure cannot undo side effects already performed by earlier resources.

Prefer the most local precondition that explains the violated assumption. If a module has many downstream resources that rely on one data-source property, a precondition on that data source can stop the graph near the source of the bad result. If the rule belongs to one resource only, attaching it to that resource gives a more actionable diagnostic. A broad condition at an unrelated output may report the failure too late.

Postconditions enforce result guarantees

A postcondition describes what must be true after Terraform has planned or read the object. It is useful when the configuration depends on a property returned by a provider and downstream actions must not proceed unless the result meets a contract. For example, a data source may select the most recent image, but the chosen image must have a required owner or classification tag.

data "aws_ami" "approved" {
  owners      = ["self"]
  most_recent = true

  filter {
    name   = "name"
    values = ["approved-base-*"]
  }

  lifecycle {
    postcondition {
      condition     = self.tags["ReleaseChannel"] == "stable"
      error_message = "The selected image must be in the stable release channel."
    }
  }
}

A failed postcondition stops dependent work that requires the invalid result, but it does not roll back actions Terraform already completed. Design the graph so that validation happens before expensive or irreversible consumers where possible. If the provider operation itself has side effects, treat the condition as a guard on downstream actions, not as a transaction or rollback mechanism.

Postconditions can protect module outputs too. An output precondition can prevent a value from being exposed or recorded if it violates a module-level expectation. That is often clearer than duplicating the same assertion in every consumer. Choose the boundary that owns the guarantee: a child module can validate its output contract, while the caller can add a precondition for a caller-specific assumption.

Checks observe without blocking

A check block is designed for assertions outside an individual resource’s lifecycle, such as a health endpoint or a broader property of the resulting infrastructure. Check blocks run at the end of a plan or apply operation. If an assertion fails, Terraform reports a warning and continues the current operation. This behavior makes checks useful for visibility and continuous validation, but unsuitable when the same failed condition must prevent an apply.

check "service_endpoint" {
  data "http" "health" {
    url = var.health_url
  }

  assert {
    condition     = data.http.health.status_code == 200
    error_message = "The service health endpoint did not return HTTP 200."
  }
}

The HTTP data source shown here requires an appropriate provider requirement and plugin. It also performs an external request, which can fail because of DNS, TLS, routing, rate limits, or endpoint behavior. Decide whether a failed request should make the entire operation fail, produce a health warning, or move to a separate monitoring system. A Terraform check is not a replacement for an SLO monitor, synthetic test fleet, or incident alert pipeline.

Because a check is intentionally non-blocking, using one for a mandatory account, network, or data integrity rule can allow an apply to continue after Terraform has told the operator the condition is false. Use a variable validation, precondition, or postcondition for invariants that must stop the workflow. Use a check when warning and continued execution are the desired policy.

Unknown values and graph behavior

Terraform evaluates conditions as early as it can, but the graph may contain values that are unknown during planning. If a condition depends on a provider-generated ID or an attribute returned only after resource creation, Terraform may defer it until apply. If a postcondition fails after an upstream resource was created, the resource is not automatically rolled back. The operator must understand the partial state and any external effects.

Do not add arbitrary dependencies solely to force a condition to run later or earlier. A dependency can change graph ordering and cause additional replacement or latency. Prefer references that express the real data flow, keep conditions close to the values they protect, and test both plan-known and apply-known cases with representative provider behavior. When possible, use a mock in a Terraform test to cover deterministic logic, then use a controlled integration environment for provider-dependent cases.

Failure messages should be useful without exposing sensitive values. Terraform can include evaluated expressions in diagnostics, so avoid formatting credentials, tokens, private keys, or sensitive infrastructure outputs into an error string. Name the failed assumption and suggest a safe corrective action. In reusable modules, tests should assert not just that an invalid input fails but that the intended condition owns the failure.

Put each rule in the right layer

Use variable validation for caller-controlled syntax and ranges. Use a precondition for assumptions that must hold before an object is created or consumed. Use a postcondition for required properties of provider results or module outputs. Use a check for non-blocking observations about infrastructure behavior. Use policy-as-code or an organizational deployment gate for requirements that must be enforced consistently across many configurations and teams.

These layers complement each other. A module can validate an input port, confirm that a data source returned an acceptable network, assert that a created resource exposes the promised output, and run a health check that reports a warning. Each condition has a different owner, timing, and failure policy. Duplicating the same condition everywhere creates noise; placing it at the wrong boundary can either block too late or fail to block at all.

Test the diagnostic, not just the expression

Keep a positive and negative case for each critical invariant. A test should demonstrate that a valid configuration reaches a usable plan and that an invalid configuration reports the expected condition. For a check block, test the success result and confirm that a failed check is treated as a warning rather than as an apply blocker. For postconditions based on provider results, mock deterministic values and separately test a safe integration path if the real provider behavior matters.

When reviewing a failure, identify whether Terraform knew the condition during planning or deferred it until apply. Inspect which prior actions completed and whether dependent actions were stopped. This is essential in incidents: an error does not necessarily mean the entire operation was rolled back. Preserve the plan, apply output, state version, and provider diagnostics before attempting manual recovery.

Custom conditions become reliable when their semantics match their placement. Decide what must be true, when the value is knowable, and whether a failure should block. Then write one precise expression, a safe message, and a test that proves the intended boundary.

Related:

Sources:

Comments