Terraform #03 — Variables, Outputs & Locals
Input variables, validation, sensitive values, outputs, and locals — the building blocks for flexible, reusable Terraform code.
Goal: Get comfortable with Terraform's input/output system so your code is flexible, reusable, and safe. This is the foundation for building modules (Module 06) and managing multiple environments (Module 07).
3.1. Input variables — variable
Variables are input parameters that stop you from hardcoding values all over the place.
variable "instance_type" {
description = "EC2 instance type" # always write a description!
type = string
default = "t3.micro" # has a default → optional; no default → required
}Variable attributes
| Attribute | Meaning |
|---|---|
type | Data type (see below) |
default | Default value; if omitted, the caller must provide a value |
description | Shown in docs and plan prompts |
sensitive | true → hides value in output and logs |
nullable | false → disallows null |
validation | Custom validation rules (see 3.4) |
Variable types
# Primitive types
variable "name" { type = string }
variable "count" { type = number }
variable "enabled" { type = bool }
# Collection types
variable "azs" { type = list(string) }
variable "tags" { type = map(string) }
variable "ports" { type = set(number) }
# Structural types — very common in practice
variable "database" {
type = object({
engine = string
instance_class = string
allocated_gb = number
multi_az = bool
})
default = {
engine = "postgres"
instance_class = "db.t3.micro"
allocated_gb = 20
multi_az = false
}
}
# Optional attributes (Terraform 1.3+) — attributes that can be omitted, with defaults
variable "server" {
type = object({
name = string
port = optional(number, 8080) # if not provided → 8080
tags = optional(map(string), {})
})
}
# any — flexible but loses type safety (use sparingly)
variable "anything" { type = any }3.2. How values get loaded (precedence order)
Terraform loads variables in this order, with later sources overriding earlier ones:
1. Default value in declaration ← lowest priority
2. terraform.tfvars / *.auto.tfvars files
3. TF_VAR_<name> environment variables
4. -var-file passed on the command line
5. -var flag on the command line ← highest priority
Examples
# 1. terraform.tfvars (loaded automatically)
# instance_type = "t3.large"
# 2. *.auto.tfvars (loaded automatically, alphabetically)
# prod.auto.tfvars
# 3. Environment variable (TF_VAR_ prefix)
export TF_VAR_instance_type="t3.large"
# 4. Specific file
terraform apply -var-file="prod.tfvars"
# 5. Direct flag
terraform apply -var="instance_type=t3.large"In practice: keep one tfvars file per environment (
dev.tfvars,prod.tfvars), pass it via-var-file. Don't commit tfvars files that contain secrets.
3.3. Output values — output
Outputs expose values after apply: displayed in the terminal, accessible to parent modules, and readable by other projects.
output "instance_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "db_password" {
value = aws_db_instance.main.password
sensitive = true # hidden in CLI output (but still in state in plaintext!)
}
output "lb_dns" {
value = aws_lb.main.dns_name
description = "DNS name to point your domain at"
depends_on = [aws_lb_listener.https] # rarely needed, but possible
}Outputs are a module's "public API." A parent module reads a child's outputs via
module.<name>.<output>.
Reading outputs
terraform output # all outputs
terraform output instance_ip # specific output
terraform output -json # JSON format (for scripts/CI)
terraform output -raw instance_ip # raw value without quotes (for shell)3.4. Validation — catching bad inputs early
Validation fails at plan time with a clear message, instead of letting the cloud API return a cryptic error.
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 >= 1 && var.instance_count <= 10
error_message = "instance_count must be between 1 and 10."
}
}
variable "bucket_name" {
type = string
validation {
condition = can(regex("^[a-z0-9-]+$", var.bucket_name))
error_message = "bucket_name can only contain lowercase letters, numbers, and hyphens."
}
}Terraform 1.9+ allows
validationto reference other variables (cross-variable validation), not just the variable being declared.
3.5. Locals — computed intermediate values
Locals let you compute a value once and reuse it. Unlike variables, they can't be set from outside — they're calculated inside the module.
locals {
# Common tags — DRY
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
Owner = var.team
}
# Consistent naming
name_prefix = "${var.project_name}-${var.environment}"
# Conditional logic
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
# Can reference other locals
bucket_name = "${local.name_prefix}-assets"
}
resource "aws_instance" "web" {
instance_type = local.instance_type
tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })
}Variable vs Local vs Output — at a glance
| Variable | Local | Output | |
|---|---|---|---|
| Direction | In (input) | Internal | Out (output) |
| Set from outside? | Yes | No | No |
| Purpose | Parameterize | Compute/DRY | Export results |
| Reference with | var.x | local.x | output (parent reads it) |
3.6. Sensitive data
variable "db_password" {
type = string
sensitive = true
}When sensitive = true:
- ✅ Value is hidden (
(sensitive value)) inplanandapplyoutput. - ✅ "Contagious" — any expression using it is also marked sensitive.
- ❌ Still stored in state as plaintext — this is the part people often miss.
⚠️
sensitiveprotects against screen/log exposure. It doesn't encrypt state. Securing the state file (Module 02) is still required. For real secrets, pull from Vault or Secrets Manager at runtime (Module 09).
3.7. File organization conventions
A standard root module or child module splits files by role:
.
├── main.tf # primary resources
├── variables.tf # all variable declarations
├── outputs.tf # all output declarations
├── locals.tf # locals (if there are many)
├── providers.tf # provider config + terraform block
├── versions.tf # required_version + required_providers
├── terraform.tfvars # default values (be careful with secrets)
└── *.auto.tfvars
Terraform doesn't care about file names — it loads every
.tffile in the directory. The split is purely for humans. Stick to this convention and your team will thank you.
3.8. Hands-on Labs
Go to hands-on/ and follow LAB.md:
- Lab 1 — Variables of all types, locals, and outputs (using the local provider).
- Lab 2 — Validation: intentionally pass bad values to see the error messages.
- Lab 3 — Precedence order: tfvars vs TF_VAR vs -var.
- Lab 4 — Sensitive: watch the value get hidden in plan output.
✅ Module 03 completion criteria
- Can declare a variable with an
objecttype includingoptionalattributes. - Understand the variable loading precedence order.
- Can write validation using
containsandcan(regex(...)). - Know the difference between variable, local, and output.
- Understand the limitation of
sensitive(doesn't encrypt state).
➡️ Next: Module 04 — Expressions & Functions