Skip to content
>_higan
This post has a translation:Read in Tiếng Việt

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

AttributeMeaning
typeData type (see below)
defaultDefault value; if omitted, the caller must provide a value
descriptionShown in docs and plan prompts
sensitivetrue → hides value in output and logs
nullablefalse → disallows null
validationCustom 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 validation to 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

VariableLocalOutput
DirectionIn (input)InternalOut (output)
Set from outside?YesNoNo
PurposeParameterizeCompute/DRYExport results
Reference withvar.xlocal.xoutput (parent reads it)

3.6. Sensitive data

variable "db_password" {
  type      = string
  sensitive = true
}

When sensitive = true:

  • ✅ Value is hidden ((sensitive value)) in plan and apply output.
  • ✅ "Contagious" — any expression using it is also marked sensitive.
  • Still stored in state as plaintext — this is the part people often miss.

⚠️ sensitive protects 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 .tf file 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:

  1. Lab 1 — Variables of all types, locals, and outputs (using the local provider).
  2. Lab 2 — Validation: intentionally pass bad values to see the error messages.
  3. Lab 3 — Precedence order: tfvars vs TF_VAR vs -var.
  4. Lab 4 — Sensitive: watch the value get hidden in plan output.

✅ Module 03 completion criteria

  • Can declare a variable with an object type including optional attributes.
  • Understand the variable loading precedence order.
  • Can write validation using contains and can(regex(...)).
  • Know the difference between variable, local, and output.
  • Understand the limitation of sensitive (doesn't encrypt state).

➡️ Next: Module 04 — Expressions & Functions

Share: