Variables, Outputs, Locals and Catching Bad Values Early
So far every value in the configuration has been hardcoded: the region typed in directly, the bucket name nailed to a prefix. That's not reusable — to build the same infrastructure for dev and prod you'd have to copy the whole file and edit by hand, and mistakes are hard to avoid. Part III makes configuration flexible. This article covers the three foundational pieces: variables bring values in, outputs take results out, locals name expressions. And the most valuable part: how to catch bad values before they get a chance to create broken infrastructure.
Goal
Parameterize a configuration with variables/outputs/locals, and build a guardrail of checks with validation, precondition, and postcondition so errors surface at the plan step instead of mid-apply.
variable: the configuration's input
A variable declares a value passed in from outside:
variable "environment" {
type = string
description = "Môi trường: dev | staging | prod"
default = "dev"
}
type constrains the kind (string, number, bool, or composite types like list(string), map(...), object({...})). description lets others understand what the variable is for. default gives a default value — with no default the variable becomes required, and whoever runs it must supply it.
There are several ways to pass values, in increasing order of precedence: the terraform.tfvars file (auto-loaded), the environment variable TF_VAR_environment=staging, and the command-line flag -var environment=staging (highest). In practice, a separate .tfvars file per environment is a tidy way to run the same code for multiple environments (Part V).
output: the configuration's output
output publishes a value after apply — for you to view, or for another configuration to read back (via remote state, Article 16):
output "bucket_name" {
value = aws_s3_bucket.app.id
}
An output takes a description, and sensitive = true to hide the value (Article 8). Beyond printing at the end of apply, outputs are a module's public interface — Part IV will show a module returning results to the outside through its outputs.
locals: naming an expression
locals assigns a name to an expression so you can reuse it in many places without repetition:
locals {
name_prefix = "${var.project}-${var.environment}"
is_production = var.environment == "prod"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
Reference it with local.name_prefix. The difference from a variable: a variable takes a value from outside, a local computes a value from inside the configuration (usually from other variables). Use a local when a derived value repeats in many places (like a common tag set), or when a complex expression needs a meaningful name.
The docs also warn against overuse: locals "can make a configuration harder to read by obscuring where a value actually comes from." When you read local.x you have to jump off to find its definition. Use them in moderation — only when the benefit (avoiding repetition, naming clearly) truly outweighs that.
Putting the three together, a parameterized bucket looks like:
resource "aws_s3_bucket" "app" {
bucket_prefix = "${local.name_prefix}-"
force_destroy = var.force_destroy
tags = local.common_tags
}
With this same file, switching -var environment=staging yields a different bucket for a different environment, without editing a single line of code.
validation: block bad input right at plan
This is the important part. A string variable accepts any string, including a mistyped "pruduction" or a wrong-case "PROD". Without a check, the bad value travels deep into the configuration and causes a confusing error somewhere else. The validation block (available since Terraform 0.13) checks right when the variable loads:
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment phải là một trong: dev, staging, prod."
}
}
Pass a value outside the list:
$ terraform plan -var environment=production
Error: Invalid value for variable
on main.tf line 26:
26: variable "environment" {
environment phải là một trong: dev, staging, prod.
This was checked by the validation rule at main.tf:32,3-13.
Terraform stops right at the plan step, prints exactly the message you wrote, and hasn't called a single API. A clear error_message is what makes the difference: the user knows immediately what's wrong and how to fix it, instead of guessing from a vague AWS error somewhere far away.
precondition and postcondition: check assumptions around a resource
Validation checks the value of one variable. But many assumptions live at the level of relationships between things — "in prod you can't enable force_destroy," "the AMI must be the x86_64 architecture." That's the job of precondition and postcondition (available since Terraform 1.2), placed in a resource's lifecycle block:
resource "aws_s3_bucket" "app" {
bucket_prefix = "${local.name_prefix}-"
force_destroy = var.force_destroy
lifecycle {
precondition {
condition = !local.is_production || !var.force_destroy
error_message = "Ở prod không được bật force_destroy trên bucket."
}
}
}
The condition reads "either not prod, or force_destroy not enabled" — meaning it forbids exactly the prod-plus-force_destroy combination. Try it with prod:
$ terraform plan -var environment=prod
Error: Resource precondition failed
on main.tf line 64, in resource "aws_s3_bucket" "app":
64: condition = !local.is_production || !var.force_destroy
Ở prod không được bật force_destroy trên bucket.
The timing difference between the two: precondition runs after the plan is formed but before the resource is created, checking input assumptions (which is why it can't reference self — the resource doesn't exist yet). postcondition runs after apply or after reading a data source, checking that the result is as expected (and can use self to inspect attributes of the just-created resource). A typical postcondition: after creating an instance, ensure it's really in the intended VPC.
variable loaded plan done apply apply done
─────────── ───────── ───── ──────────
validation ──► precondition ──► [create/modify rsrc] ──► postcondition
(variable value) (input assumptions) (result correct?)
check (warns, doesn't block)
check: checks outside the lifecycle, warning only
There's one more kind, the check block (Terraform 1.5), which checks "infrastructure outside the normal resource lifecycle" and only warns rather than blocking apply. It fits monitoring conditions that shouldn't break a deploy but you still want to know about (for example an endpoint returning 200). We'll save this for Article 17, when covering advanced lifecycle features.
🧹 Cleanup
$ terraform destroy -auto-approve
Destroy complete! Resources: 1 destroyed.
Wrap-up
Variables bring values in from outside (type/description/default, passed via tfvars/TF_VAR_/-var), outputs publish results, locals name derived expressions but shouldn't be overused. Three layers of checks catch errors each at its own moment: validation blocks a bad variable value at load, precondition checks input assumptions before creating a resource, postcondition checks the result after creation, and check warns outside the lifecycle. The common thread: errors surface at plan with a message you wrote yourself, instead of blowing up mid-apply with a hard-to-read AWS error.
The next article gets into what HCL can really do: data sources to read existing information on AWS (the latest AMI, available AZs), functions and for expressions, and the dynamic block to generate repeated configuration without copying by hand.