Terraform check blocks on AWS resources — preconditions, postconditions, checks
A plan can look green while the assumptions are wrong. Put the assumptions next to the resources that need them.
<!-- hal:authoritative:yaml -->
A plan can look green while the assumptions are wrong. Put the assumptions next to the resources that need them.
§I — Frame
Saturday this arc taught a consumer stack how to read network outputs through terraform_remote_state. The Cert seat the same day shaped terraform.workspace and a prod guard. Neither shirt is today's job.
Today the track is Terraform on AWS. The concrete referent is an EC2 instance and a security group that must refuse unsafe combinations before apply, and must prove a few facts after apply. Variable validation catches bad inputs at the edge. Lifecycle preconditions catch bad combinations at plan and apply time. Postconditions catch lies in the result. Check blocks catch advisory quality without failing the apply.
Lab 07 names the quartet: variable validation, precondition, check block, and a basic terraform test. Lab 13 already spent workspace-shaped prod guards on Cert. Keep that prior spend. Today scopes conditions onto AWS resources themselves.
Brikman Chapter 9 still owns the wider testing story: plan testing, integration tests, end-to-end tests. Custom Conditions are the HCL-native layer that sits under those tests. Write the conditions first. Then let terratest and plan JSON tools prove they fire.
§II — Foundations: four instruments
Fact one. Variable validation is the front door.
variable "environment" {
type = string
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be one of: dev, stage, prod."
}
}
variable "instance_type" {
type = string
validation {
condition = can(regex("^t3\\.", var.instance_type)) || can(regex("^m5\\.", var.instance_type))
error_message = "instance_type must be a t3.* or m5.* family string."
}
}
Validation runs when the variable is set. It does not see other resources. It cannot compare var.environment to an AMI owner ID that only exists after a data source resolves. Use it for shape and allowlists.
Fact two. Lifecycle precondition is the hard stop before create or update.
resource "aws_instance" "app" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.app.id]
tags = {
Name = "${var.name_prefix}-${var.environment}"
Environment = var.environment
}
lifecycle {
precondition {
condition = !(var.environment == "prod" && var.instance_type == "t3.micro")
error_message = "prod must not use t3.micro."
}
precondition {
condition = var.subnet_id != ""
error_message = "subnet_id must be set before aws_instance.app can plan."
}
}
}
Lab 07 encodes the same prod-versus-t3.micro rule on terraform_data. Lift it onto aws_instance when the cloud referent matters. A failing precondition fails plan and apply for that resource. The apply does not partially succeed past the bad node.
Fact three. Lifecycle postcondition judges the result.
lifecycle {
postcondition {
condition = self.public_ip == "" || var.environment != "prod"
error_message = "prod instances must not receive a public IP."
}
postcondition {
condition = contains(keys(self.tags), "Environment")
error_message = "Environment tag must be present after apply."
}
}
Postconditions run after the provider returns the object. They see self. Use them for invariants the plan cannot know: public IP presence, attachment state, tag merges from a provider defaulting path. A failing postcondition fails the apply after the API call. Fix the configuration. Re-apply carefully.
Fact four. Check blocks are advisory quality, not hard policy.
check "sg_ingress_ssh_scope" {
assert {
condition = alltrue([
for r in aws_security_group.app.ingress :
!(r.from_port == 22 && r.cidr_blocks == ["0.0.0.0/0"])
])
error_message = "SSH should not be open to 0.0.0.0/0 on aws_security_group.app."
}
}
check "name_prefix_quality" {
assert {
condition = length(var.name_prefix) >= 5
error_message = "name_prefix should usually be at least 5 characters for readability."
}
}
Lab 07's name_prefix_quality check warns when the prefix is short. Checks report during plan and apply. They do not replace IAM policy or a Sentinel gate. Treat them as continuous lint inside the graph.
§III — Worked AWS shape
One module. One instance. One security group. One data source for the AMI. Preconditions block prod micro and empty subnet. Postconditions refuse prod public IP. Checks warn on wide-open SSH and short names.
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
data "aws_subnet" "selected" {
id = var.subnet_id
}
resource "aws_security_group" "app" {
name_prefix = "${var.name_prefix}-${var.environment}-"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = var.ingress_cidrs
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = data.aws_subnet.selected.id
vpc_security_group_ids = [aws_security_group.app.id]
tags = {
Name = "${var.name_prefix}-${var.environment}"
Environment = var.environment
}
lifecycle {
precondition {
condition = !(var.environment == "prod" && var.instance_type == "t3.micro")
error_message = "prod must not use t3.micro."
}
precondition {
condition = data.aws_subnet.selected.vpc_id == var.vpc_id
error_message = "subnet_id must belong to var.vpc_id."
}
postcondition {
condition = self.public_ip == "" || var.environment != "prod"
error_message = "prod instances must not receive a public IP."
}
}
}
Plan with TF_VAR_environment=prod TF_VAR_instance_type=t3.micro and watch the precondition fail before AWS sees a RunInstances call. That failure is the lesson. The green plan under dev plus t3.micro is the control.
§IV — Failure modes and apply discipline
When a precondition fails, Terraform prints the resource address and the error_message. The plan ends. No RunInstances call leaves your laptop. That is success for the rule author.
When a postcondition fails, the provider call may already have created the instance. Terraform then reports the apply failure and leaves state reflecting the real object. Leave the resource in state until you understand it. Run terraform state show aws_instance.app. Record public IP, subnet, and tags. Change the configuration so the postcondition can pass. Plan again. Confirm update-in-place versus replace. Apply during the change window if replace is required. Add a regression test so the bad combination cannot return quietly.
When a check fails, plan and apply can still succeed. The CLI shows warnings. Treat repeated check warnings as debt, then promote the rule into a precondition once the org agrees it is mandatory.
Variable validation failures name the variable, not the resource. Fix the tfvars or the pipeline input.
Postcondition failures are expensive because the cloud call already happened. Prefer preconditions for anything you can decide before the API. Reserve postconditions for facts only the provider response can prove.
§V — What belongs outside HCL
Organization-wide bans belong in policy-as-code engines that scan every workspace. Check blocks inside one root module cannot see sibling stacks. HCP Terraform policy sets, OPA or Conftest over plan JSON, and account SCPs cover the fleet. Custom Conditions cover the module author's local assumptions.
Do not put expressions in backend blocks to validate state keys. Backend configuration stays static. Saturday's remote_state lesson already drew that line.
Today's Cert lesson maps those layers for Associate and Pro-depth study. Today's Go lesson parses plan JSON so a PR bot can surface check results. Today's terratest lesson asserts that the prod-micro precondition fails on purpose.
§V.B — Naming error messages so humans can act
Write every error_message as an order plus a fact. Bad: "invalid configuration." Better: "prod must not use t3.micro." Best: "prod must not use t3.micro; set instance_type to m5.large or move environment off prod."
Include the resource address in the prose only when the check spans multiple resources. Terraform already prints the address for lifecycle conditions. Duplicating it wastes space. For check blocks, name the check label clearly: check "sg_ingress_ssh_scope" beats check "quality".
When two preconditions exist on one resource, order them from cheapest to most specific. Empty subnet first. Prod-micro second. VPC membership third. Engineers read the first failure; later failures stay hidden until the first is fixed.
§VI — How this differs from last TF Ops
09-05 spent producer and consumer state objects. 09-02 spent Azure replace triggers. 08-30 spent GCP Private Google Access. Today does not reopen remote state keys, Key Vault URIs, or PGA flags.
The operational claim is narrower: assumptions belong next to the resource. Remote state tells you what another stack published. Checks tell you whether this stack is allowed to proceed.
§VII — Operator checklist
- Put allowlists in
validationblocks on variables. - Put cross-variable and cross-data-source rules in
lifecycle.precondition. - Put result invariants in
lifecycle.postconditionwithself. - Put advisory lint in
checkblocks. - Keep hard org policy in IAM, SCPs, and HCP Terraform policy sets.
- Add a
terraform testthat expects the prod-micro failure (Lab 07expect_failurespattern). - Parse plan JSON for check results in today's Go companion when you need a PR comment.
§VIII — Closing
Ship the module with the four instruments labeled in comments. Run one failing plan and one passing plan. Paste both into the PR. Then stop. The next improvement is a test, not another abstraction layer.
Examine the error messages. If an engineer cannot tell which rule fired, rewrite the message until the rule name and the forbidden combination are obvious.
Related
- Paired Dev (terratest): Archmagus-Stack/Polyglot-Dev/Go/2026-09-08-terratest-asserting-check-block-failures-and-plan-tests/lesson.md
- Paired Cert: Archmagus-Stack/Cert-Prep/HashiCorp/2026-09-08-terraform-associate-testing-validation-check-blocks-and-policy/lesson.md
- Paired Go (plan JSON): Archmagus-Stack/Polyglot-Dev/Go/2026-09-08-go-terraform-json-parsing-plan-and-check-results/lesson.md
- Prior Ops: Archmagus-Stack/01-Earth-DevOps/Synthesis-Lessons/2026-09-05-terraform-remote-state-data-source-across-aws-network-and-app-stacks/lesson.md
- Lab 07: Archmagus-Stack/Sovereign-Bootcamp/tfpro-labs/labs/07-validation-checks-tests-broken/README.md
- Language hub: Archmagus-Stack/Polyglot-Dev/_syllabus-ledger.md