Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Terraform By James Joyner IV · · 8 min read

Catching Bad Infrastructure Early With Terraform Check Blocks and Assertions

Validation, preconditions, postconditions, and check blocks each catch failures at a different moment. Knowing which to use where prevents a lot of 2am surprises.

  • #terraform
  • #validation
  • #check-blocks
  • #assertions
  • #testing
  • #reliability

A surprising amount of infrastructure breakage isn’t caused by a wrong resource — it’s caused by a correct resource that ended up in a wrong state. The security group applied cleanly but left port 22 open to the world. The bucket created fine but isn’t actually serving traffic. The certificate provisioned but won’t validate. Terraform said Apply complete! and went home, and the problem surfaced hours later.

Terraform has four distinct mechanisms for asserting “this had better be true,” and they fire at four different moments. Most people only use one of them. Learning where each belongs is one of the highest-leverage things you can do for infrastructure reliability.

The four guardrails and when they fire

Think of these as a timeline across a single Terraform run:

  1. Variable validation — fires at plan time, before anything happens, on input values.
  2. Preconditions — fire at plan time, before a resource is created, on assumptions.
  3. Postconditions — fire at apply time, after a resource is created, on its actual attributes.
  4. Check blocks — fire at the end of every plan and apply, as continuous assertions that don’t block.

The mental model: validation and preconditions stop bad inputs before you build. Postconditions stop bad outputs as you build. Check blocks watch without ever failing the run.

Variable validation: reject garbage at the door

The cheapest guardrail. Catch a malformed input before Terraform does any work:

variable "environment" {
  type = string

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

variable "instance_count" {
  type = number

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

Modern Terraform lets validation blocks reference other variables too, so you can express relationships like “this CIDR must contain that subnet” instead of validating each field in isolation.

Preconditions: assert your assumptions before building

A precondition lives inside a resource’s lifecycle block and guards an assumption that, if false, means the resource shouldn’t be built at all:

data "aws_ami" "app" {
  most_recent = true
  owners      = ["self"]
  filter {
    name   = "name"
    values = ["app-base-*"]
  }
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.app.id
  instance_type = "t3.medium"

  lifecycle {
    precondition {
      condition     = data.aws_ami.app.architecture == "x86_64"
      error_message = "Selected AMI is not x86_64; refusing to launch."
    }
  }
}

Without this, an arm64 AMI sneaking into your filter results launches an instance that can’t run your x86 binaries — and you find out from a crash loop, not from Terraform.

Postconditions: verify the result is actually correct

Postconditions check the resource after it’s created, against its real attributes. This is where you catch the “applied cleanly but wrong” failures:

resource "aws_s3_bucket" "assets" {
  bucket = "my-app-assets"

  lifecycle {
    postcondition {
      condition     = self.bucket_regional_domain_name != ""
      error_message = "Bucket has no regional domain name; provisioning incomplete."
    }
  }
}

resource "aws_security_group" "web" {
  name = "web-sg"
  # ... ingress rules ...

  lifecycle {
    postcondition {
      condition = alltrue([
        for rule in self.ingress :
        !(contains(rule.cidr_blocks, "0.0.0.0/0") && contains(rule.from_port == 22 ? [true] : [], true))
      ])
      error_message = "Refusing: SSH is open to the world."
    }
  }
}

That self reference is the key — postconditions see the resource’s resolved, post-apply attributes, which is exactly what you want to assert against.

Check blocks: continuous assertions that don’t block the apply

Check blocks are the newest and most misunderstood. Unlike the others, a failing check does not fail your run — it emits a warning. That sounds useless until you realize the point: checks are for ongoing health assertions you want surfaced on every plan, including drift detection runs, without ever blocking a deploy.

check "health_endpoint" {
  data "http" "app" {
    url = "https://app.example.com/healthz"
  }

  assert {
    condition     = data.http.app.status_code == 200
    error_message = "App health endpoint returned ${data.http.app.status_code}."
  }
}

check "cert_expiry" {
  assert {
    condition     = timecmp(plantimestamp(), aws_acm_certificate.main.not_after) < 0
    error_message = "Certificate is expired or expiring imminently."
  }
}

A check can embed its own scoped data source — so it can reach out and verify the thing actually works end to end, not just that Terraform created it. Run a plan on a schedule and your check blocks become a lightweight, free monitoring layer that lives next to the infrastructure it watches.

Choosing the right one

The rule I give my teams:

  • Bad input? Variable validation.
  • Bad assumption about a dependency? Precondition.
  • Bad result that must block the deploy? Postcondition.
  • Ongoing health you want visible but not blocking? Check block.

The most common mistake is reaching for a check block when you wanted a postcondition. If a broken result must stop the apply, a check won’t do it — it only warns. Conversely, don’t put a flapping external health check in a postcondition, or a transient blip fails your whole apply.

Where this pays off

These guardrails turn silent, hours-later failures into loud, immediate ones at exactly the right moment in the lifecycle. They’re also self-documenting: an error_message explains the invariant to the next engineer better than any comment.

When you’re adding assertions across a large module, it’s easy to put the right check at the wrong phase — a subtle bug that passes review because it looks correct. Running these changes through a code review workflow helps catch phase mismatches. For more on validating infrastructure before it bites, see our other Terraform guides.

Assertion semantics differ by Terraform version. Verify behavior against your current release, especially for check blocks and cross-variable validation.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.