Learning PathsTerraform
TerraformChapter 4·BEGINNER·10 min read

Variables, Locals, and Outputs

Input variable types and validation, every place a value can come from (tfvars, auto.tfvars, TF_VAR_ env vars), when to reach for locals instead of repeating an expression, and how to design outputs that hold up across environments.

Prerequisites
  • Chapter 3: Configuration Language (HCL) Basics
You'll learn to
  • Declare typed input variables with defaults, validation, and descriptions
  • Know every precedence layer Terraform checks for a variable's value, in order
  • Use locals to eliminate repeated expressions without overusing them
  • Choose complex types (object, map, list, tuple) correctly for real inputs

Variables, Locals, and Outputs

1. Why does this exist?

Chapter 1’s mini project hardcoded a bucket name and a region directly into resource blocks. That’s fine for a five-minute experiment — but the moment you need a second environment, or a teammate needs to run the same configuration with different values, hardcoding breaks down. Variables are how a Terraform configuration accepts input instead of encoding every value as a literal; locals are how you name a computed expression once instead of repeating it; outputs are how a configuration hands values back out — to a human reading terraform output, to a CI pipeline, or to another Terraform configuration entirely (Chapter 9 covers this last case with modules).

2. Concept

Variable Local Output
Direction Input, comes from outside the configuration Internal, computed from other values Output, exposed to whoever runs/consumes this configuration
Declared with variable "name" { ... } locals { name = ... } output "name" { ... }
Referenced as var.name local.name (not referenced within the same configuration — consumed externally)
Typical use Environment name, instance size, region A computed naming convention, a filtered list, a merged tags map A resource’s ID, a connection endpoint, a computed URL

Variable Types

Type Example Use case
string "us-east-1" Region, name, environment
number 3 Instance count, port number
bool true Feature flag, enable/disable toggle
list(string) ["a", "b", "c"] Ordered set of AZ names, CIDR blocks
map(string) { env = "prod", team = "platform" } Tags, arbitrary key/value config
object({...}) object({ name = string, size = number }) A structured, typed group of related settings
tuple([...]) tuple([string, number, bool]) A fixed-length, mixed-type sequence (rare in practice)
any Escape hatch when you genuinely don’t want to constrain the type — use sparingly

Precedence Order (highest wins)

  1. -var and -var-file flags on the command line
  2. *.auto.tfvars files (loaded automatically, alphabetical order)
  3. terraform.tfvars (loaded automatically)
  4. TF_VAR_<name> environment variables
  5. The variable’s default value in its declaration

3. Architecture Diagram

flowchart TD
    CLI["-var / -var-file<br/>(highest precedence)"] --> Merge
    Auto["*.auto.tfvars"] --> Merge
    TFVars["terraform.tfvars"] --> Merge
    Env["TF_VAR_* env vars"] --> Merge
    Default["default in variable block<br/>(lowest precedence)"] --> Merge
    Merge["Final resolved value"] --> Config[Used throughout configuration as var.name]

4. Visual Explanation

Variable Block Anatomy

variable "instance_type" {
  type        = string
  default     = "t3.micro"
  description = "EC2 instance type for the web tier"

  validation {
    condition     = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
    error_message = "instance_type must be one of: t3.micro, t3.small, t3.medium."
  }
}
Hint: what happens if validation fails?

Terraform stops at plan time with your custom error_message — before ever contacting a provider. This is meaningfully better than letting an invalid value reach the cloud API and fail with a generic, less helpful error, or worse, silently succeed with unintended infrastructure.

5. Example

# variables.tf
variable "environment" {
  type        = string
  description = "Deployment environment name"
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

variable "tags" {
  type        = map(string)
  default     = {}
  description = "Additional resource tags"
}

# main.tf
locals {
  common_tags = merge(var.tags, {
    Environment = var.environment
    ManagedBy   = "terraform"
  })
}

resource "aws_s3_bucket" "demo" {
  bucket = "aetoso-${var.environment}-demo"
  tags   = local.common_tags
}

# outputs.tf
output "bucket_arn" {
  value       = aws_s3_bucket.demo.arn
  description = "ARN of the demo bucket, for cross-account bucket policy references"
}

local.common_tags merges caller-supplied tags with two computed ones — every resource in this configuration can reference local.common_tags once, instead of repeating the merge logic per resource.

6. Production Example

environments/
├── dev/
│   ├── terraform.tfvars     # environment = "dev", smaller instance sizes
│   └── main.tf
├── prod/
│   ├── terraform.tfvars     # environment = "prod", larger instance sizes
│   └── main.tf
# environments/dev/terraform.tfvars
environment    = "dev"
instance_type  = "t3.micro"
instance_count = 1

# environments/prod/terraform.tfvars
environment    = "prod"
instance_type  = "t3.large"
instance_count = 3

Same main.tf in both directories (often symlinked or generated from a shared module — Chapter 9), different terraform.tfvars per environment. This is the simplest version of the “directory-per-environment” pattern covered fully in Chapter 10.

7. Code Walkthrough — Complex Types

variable "database_config" {
  type = object({
    engine         = string
    instance_class = string
    storage_gb     = number
    multi_az       = bool
  })
  description = "Structured RDS configuration"
}

variable "subnets" {
  type        = list(string)
  description = "List of subnet IDs for the ASG"
}

variable "team_tags" {
  type        = map(string)
  default     = {}
  description = "Arbitrary team-supplied tags, merged into every resource"
}

Using object({...}) for database_config instead of four separate variables groups genuinely related settings together and gives Terraform a schema to validate against — passing a string where storage_gb expects a number fails immediately with a clear type error, not a confusing downstream provider error.

8. Behind the Scenes

Terraform resolves every variable’s final value before building the dependency graph — variables are inputs to the graph, not nodes within it the way resources and data sources are. TF_VAR_<name> environment variables are read directly from the process environment at startup; .tfvars files are parsed as a restricted subset of HCL (key-value pairs only, no expressions referencing other variables); -var and -var-file are parsed last and win any conflict. This is also why you can’t reference var.other_variable inside a variable’s own default — defaults are resolved independently, before any inter-variable relationship could be evaluated.

9. Common Mistakes

  • Wrong: Using type = any by default instead of a specific type. Why: you lose Terraform’s built-in type-checking, and errors surface later and less clearly. Correct: use any only when a variable’s shape genuinely varies by caller in a way no concrete type can express.
  • Wrong: Repeating the same three-line tag-merging expression in every resource block instead of a local. Why: any future change to the convention requires finding and updating every copy — exactly the failure mode in this chapter’s practical scenario. Correct: compute it once in locals, reference local.common_tags everywhere.
  • Wrong: Putting secrets directly into a .tfvars file that gets committed to Git. Why: .tfvars files are plain text, and once committed, the secret is in Git history forever, even if later removed. Correct: use TF_VAR_* environment variables sourced from a secrets manager in CI, or Chapter 13’s secrets-management patterns.

10. Production Tips

  • 🚀 Best Practice: Give every variable a descriptionterraform-docs and similar tools can auto-generate module documentation directly from these.
  • 💡 Tip: Use *.auto.tfvars (loaded automatically, no -var-file flag needed) for values that should always apply in a given directory, and reserve explicit -var-file for values you want to intentionally opt into per invocation.
  • ⚠️ Warning: Never commit a .tfvars file containing real secrets — add a .gitignore entry for *.tfvars in any directory where secrets might land, and use environment variables or a secrets manager instead.
  • 🚀 Best Practice: Name outputs descriptively and consistently across a codebase (<resource>_<attribute>, e.g. db_endpoint, vpc_id) so consumers of your module don’t have to guess.

11. Debugging

Symptom Likely Cause Fix
Error: No value for required variable A variable has no default and no value was supplied anywhere Supply it via -var, a .tfvars file, or a TF_VAR_* env var; or add a sensible default if one exists
Variable value isn’t what you expected Another precedence layer is overriding what you set Check all five precedence layers, highest to lowest, for conflicting values
Error: Invalid value for variable (custom message) A validation block’s condition evaluated to false Read your own custom error_message — it should tell you exactly what’s expected
Error: Variables not allowed inside a .tfvars file Tried to reference var.x or a function inside a .tfvars file .tfvars files only support literal values, not expressions — move any computed logic into locals in a .tf file instead

12. Interview Questions

Q — Walk through Terraform’s variable precedence order and explain why command-line flags win over everything else. From lowest to highest: the variable’s own default, then TF_VAR_* environment variables, then terraform.tfvars, then *.auto.tfvars files, then -var/-var-file flags. Command-line flags win because they represent the most explicit, most immediate intent of the person or pipeline running Terraform right now — every other layer is a more ambient, less specific source of the value.

Q — When would you use a local instead of a variable? When the value is computed from other values already available in the configuration (other variables, resource attributes, data source results) rather than supplied externally. A local never accepts outside input — it exists purely to name an expression once so it isn’t repeated, improving both readability and single-source-of-truth maintenance.

Q — Why can’t a variable’s default reference another variable? Terraform resolves all variables’ values before the configuration’s dependency graph is built, and variables aren’t part of that graph the way resources are — there’s no defined evaluation order between two variables’ defaults. If you need one value derived from another, that’s exactly what a local is for, since locals are evaluated within the graph and can safely reference variables.

13. Hands-on Lab

Goal: build a small variable-driven configuration with validation, using local_file (no cloud account needed).

  1. Declare a variable environment (string, no default) with a validation block restricting it to dev/staging/prod.
  2. Declare a locals block computing a filename: "${var.environment}-config.txt".
  3. Create a local_file resource using that computed filename, with content describing the environment.
  4. Run terraform plan -var="environment=qa" — observe the validation error and its custom message.
  5. Run terraform plan -var="environment=staging" — observe it succeeds.
Solution

Step 4 should fail at plan time with your custom error_message, never reaching a provider call — this is the concrete benefit of validation blocks over letting an invalid environment name flow through to unpredictable downstream behavior.

14. Mini Project

Parameterize the Chapter 1 mini project fully. Take the S3 bucket configuration:

  1. Add variables for environment, bucket_suffix, and tags (map, with a sensible default).
  2. Add a locals block computing common_tags by merging var.tags with a computed Environment and ManagedBy tag.
  3. Create two .tfvars files — dev.tfvars and prod.tfvars — with different environment and bucket_suffix values.
  4. Run terraform plan -var-file=dev.tfvars and terraform plan -var-file=prod.tfvars, confirming each produces a differently-named bucket.

16. Summary

  • Variables accept external input; locals compute internal values once for reuse; outputs expose values to whoever consumes this configuration.
  • Precedence, highest to lowest: -var/-var-file*.auto.tfvarsterraform.tfvarsTF_VAR_* env vars → the variable’s own default.
  • Use validation blocks to reject bad input at plan time with a clear message, instead of a confusing downstream error.
  • Complex types (object, list, map) give Terraform a schema to validate against — prefer them over any whenever the shape of an input is actually known.
  • Never commit real secrets in .tfvars files — use environment variables or a secrets manager instead.

17. Cheat Sheet

variable "name" {
  type        = string
  default     = "value"
  description = "..."
  validation {
    condition     = ...
    error_message = "..."
  }
}

locals {
  computed = "${var.a}-${var.b}"
}

output "name" {
  value       = local.computed
  description = "..."
}
terraform plan -var="key=value"
terraform plan -var-file="dev.tfvars"
TF_VAR_region=us-east-1 terraform plan

Next: Chapter 5 covers the full expression language — functions, conditionals, count/for_each, dynamic blocks — building directly on the variable types introduced here. Chapter 9’s modules chapter is where variables and outputs become a module’s actual public interface, not just internal configuration knobs.

🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
← Configuration Language (HCL) BasicsExpressions, Functions, and Dynamic Blocks →