Validation & Checks: Failing Fast Before You Break Prod

Validation & Checks: Failing Fast Before You Break Prod
The Cost of Discovering Errors Late

You run terraform apply. It successfully provisions 15 resources. On resource 16, it errors — the instance type is invalid for the selected region, or the CIDR block overlaps with an existing subnet. Now you have 15 resources that were created, one that failed, and an environment in a half-deployed state. Cleanup is manual, error-prone, and stressful.

The earlier you catch errors, the cheaper they are. Terraform provides mechanisms at three levels: variable validation (catches bad inputs before any resource is planned), preconditions and postconditions (validates assumptions at the resource level), and check blocks (monitors infrastructure health after deployment). Each level catches different categories of errors at different points in the workflow.

Variable Validation — Catching Bad Input at Plan Time

Validation blocks inside variable declarations run during terraform plan — before any API calls, before any resources are created or modified. They’re your first line of defense against misconfiguration.

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

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

variable "instance_type" {
  type = string

  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "Only t3 instance families are permitted in this module."
  }
}

variable "cidr_block" {
  type = string

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "cidr_block must be a valid IPv4 CIDR notation."
  }
}

When validation fails:

│ Error: Invalid value for variable
│
│   on variables.tf line 1:
│    1: variable "environment" {
│
│ Environment must be one of: dev, staging, prod.

The plan fails immediately. No resources are touched. The caller gets a human-readable error message that explains exactly what’s wrong and how to fix it. This is dramatically better than letting the bad value flow through to an API call that returns a cryptic error 45 seconds into an apply.

Why validation matters more for shared modules: when you publish a module for others to use, you can’t control what values they’ll pass. Validation creates guardrails — the module self-documents its acceptable inputs and enforces them at plan time. A module with comprehensive validation is a module that’s hard to misuse.

Multiple Validations Per Variable

You can add multiple validation blocks to a single variable. All are checked, and all failures are reported:

variable "instance_count" {
  type = number

  validation {
    condition     = var.instance_count >= 1
    error_message = "Instance count must be at least 1."
  }

  validation {
    condition     = var.instance_count <= 10
    error_message = "Instance count cannot exceed 10 for cost control."
  }
}

If someone passes instance_count = 0, they see the minimum error. If they pass instance_count = 50, they see the maximum error. Clear, specific, actionable.

A constraint on validation expressions: they can only reference the variable being validated (var.<name>). They cannot reference other variables, resources, or data sources. This is by design — validations run before the dependency graph is resolved, so they can’t depend on anything external.

Useful Validation Patterns
# Constrained set of values
validation {
  condition     = contains(["us-east-1", "us-west-2", "eu-west-1"], var.region)
  error_message = "Only approved regions are permitted."
}

# Regex pattern matching
validation {
  condition     = can(regex("^[a-z0-9-]+$", var.cluster_name))
  error_message = "Cluster name can only contain lowercase letters, numbers, and hyphens."
}

# Length constraint
validation {
  condition     = length(var.project_name) <= 20
  error_message = "Project name must be 20 characters or fewer."
}

# Valid CIDR
validation {
  condition     = can(cidrhost(var.cidr_block, 0))
  error_message = "Must be a valid CIDR block (e.g., 10.0.0.0/16)."
}

# Non-empty list
validation {
  condition     = length(var.subnet_cidrs) > 0
  error_message = "At least one subnet CIDR must be provided."
}

The can() function is key for validation — it returns true if the expression inside it evaluates without error, false otherwise. This lets you test whether a value can be parsed as a CIDR, matches a regex, or has other structural properties without causing a plan failure.

precondition and postcondition — Resource-Level Assertions

Since Terraform 1.2, you can add assertions directly to resource lifecycle blocks. These catch errors that variable validation can’t — errors that depend on actual infrastructure state.

Precondition — checked before the resource is applied:

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = data.aws_ami.selected.architecture == "x86_64"
      error_message = "The selected AMI must be for x86_64 architecture."
    }
  }
}

This catches the error before Terraform tries to launch an instance with an incompatible AMI. Without it, you’d get a confusing AWS API error 30 seconds into the apply.

Postcondition — checked after the resource is applied:

resource "aws_instance" "web" {
  ...
  lifecycle {
    postcondition {
      condition     = self.public_ip != ""
      error_message = "Instance did not receive a public IP — check subnet and network settings."
    }
  }
}

self refers to the resource being validated. Postconditions run after the resource is created, giving you a way to assert that the cloud assigned what you expected. If the instance launched in a private subnet and didn’t get a public IP, the postcondition catches it immediately with a clear message — instead of you discovering it 20 minutes later when the health check fails.

Preconditions are about assumptions: “I assume this AMI is x86_64.” Postconditions are about guarantees: “I expect this instance to have a public IP.” Together, they create a contract around each resource.

check Blocks — Continuous Infrastructure Monitoring

check blocks (Terraform 1.5+) are standalone validation blocks that run after every apply. Unlike preconditions and postconditions, a failing check block does NOT fail the apply — it produces a warning. They’re designed for monitoring invariants, not blocking deploys.

check "health_check" {
  data "http" "app" {
    url = "https://${aws_lb.app.dns_name}/health"
  }

  assert {
    condition     = data.http.app.status_code == 200
    error_message = "Health check returned ${data.http.app.status_code} — app may be unhealthy."
  }
}

check "ssl_certificate" {
  assert {
    condition     = aws_acm_certificate.app.status == "ISSUED"
    error_message = "ACM certificate is not in ISSUED state: ${aws_acm_certificate.app.status}"
  }
}

If a check fails after apply:

╷
│ Warning: Check block assertion failed
│
│   on main.tf line 45, in check "health_check":
│     │ data.http.app.status_code is 503
│
│ Health check returned 503 — app may be unhealthy.
╵

The apply succeeds. You get warned. The check runs on every subsequent apply and plan, acting as a persistent health indicator. This is useful for catching drift-like problems: the SSL certificate expired, the health endpoint stopped responding, the database is in a degraded state.

Check blocks fill a gap between Terraform’s infrastructure management and traditional monitoring. They don’t replace DataDog or CloudWatch — but they do surface infrastructure health issues at a point where someone is actively looking at Terraform output.

Validation vs. Precondition vs. Check — The Decision Matrix
MechanismWhere It LivesWhen It RunsFailure ImpactUse When
variable validationVariable blockDuring planBlocks plan entirelyBad input values — wrong types, invalid formats
preconditionResource lifecycleBefore resource applyBlocks applyPre-requisites — AMI architecture, existing resources
postconditionResource lifecycleAfter resource applyBlocks applyGuarantees — resource got the expected properties
check blockTop-levelAfter every applyWarning onlyOngoing health — endpoints responding, certs valid

The progression makes sense: validation catches the cheapest errors (bad inputs), preconditions catch the next cheapest (wrong assumptions), postconditions catch the next (unexpected cloud behavior), and checks provide ongoing monitoring. Each layer addresses a different risk at a different cost point.

Design Scenario: Module With a Rock-Solid Interface
variable "cluster_name" {
  type = string

  validation {
    condition     = length(var.cluster_name) <= 40
    error_message = "EKS cluster name must be 40 characters or fewer."
  }

  validation {
    condition     = can(regex("^[a-z0-9-]+$", var.cluster_name))
    error_message = "Cluster name can only contain lowercase letters, numbers, and hyphens."
  }
}

variable "kubernetes_version" {
  type = string

  validation {
    condition     = contains(["1.27", "1.28", "1.29", "1.30"], var.kubernetes_version)
    error_message = "Kubernetes version must be one of the supported versions: 1.27, 1.28, 1.29, 1.30."
  }
}

A module consumer runs terraform plan. They mistype the cluster name with uppercase letters. They get an immediate, human-readable error that tells them exactly what to fix — before a single API call is made. The module validates its contract before doing any work. This is how professional-grade modules behave.

Key Takeaways

Variable validation is your cheapest error-catching mechanism. It runs before anything else, requires no cloud API calls, and provides immediate, clear feedback. Every variable with a constrained value range should have a validation block. The 10 minutes it takes to write them saves hours of debugging when bad values slip through.

Check blocks are monitoring, not gatekeeping. A failing check doesn’t prevent apply — it warns you. This makes checks suitable for health indicators that might temporarily fail during deployment (like a health endpoint that returns 503 during a rolling restart) without blocking the automation.

The can() function is the key to writing robust validation conditions. It turns what would be a plan error into a boolean check: can(cidrhost(var.cidr, 0)) returns true if the CIDR is valid, false if it’s not. Use it for any validation that involves parsing or transforming the input.