Engineering Notes
Home/DevOps & Cloud/Terraform/Variables, Outputs and Locals
DevOps & Cloud · Terraform

Variables, Outputs and Locals

Make configurations reusable: typed input variables with validation, locals for derived values, and outputs for sharing. Step through variable precedence to see which value wins, and learn how unknown values flow through a plan.

☁️ DevOps & Cloud● Beginner⏱ 4 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Typed input variables, validation and sensitive values
  • Variable precedence from defaults to -var flags
  • Locals for naming conventions and derived values
  • Outputs, and how 'known after apply' values work

📚 Before you start

1Input Variables

Variables make a configuration reusable across environments and modules.

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

variable "instance_count" {
  type    = number
  default = 2
  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 20
    error_message = "instance_count must be between 1 and 20."
  }
}

variable "db_password" {
  type      = string
  sensitive = true          # redacted in plan/apply output (still stored in state!)
  nullable  = false
}

variable "services" {
  type = map(object({
    image    = string
    cpu      = optional(number, 256)       # optional attributes with defaults
    memory   = optional(number, 512)
    public   = optional(bool, false)
  }))
}

Types: string, number, bool, list(T), set(T), map(T), object({...}), tuple([...]), any. Strong types + validation blocks catch mistakes at plan time instead of during a half-finished apply.

2Setting Variable Values (Precedence, Low → High)

  1. default in the variable block
  2. environment variables TF_VAR_name
  3. terraform.tfvars, then *.auto.tfvars (alphabetical)
  4. -var-file=prod.tfvars
  5. -var 'name=value' on the command line
hcl
# prod.tfvars
environment    = "prod"
instance_count = 6
services = {
  api    = { image = "ghcr.io/acme/api:1.4.2", cpu = 512, memory = 1024, public = true }
  worker = { image = "ghcr.io/acme/worker:1.4.2" }
}

Never put secrets in .tfvars files committed to Git; pass them via TF_VAR_ from a secret store in CI, or better, have Terraform reference the secret manager directly (data source or managed passwords).

3Locals

locals are named expressions — computed once, used many times. They keep configurations DRY and readable:

hcl
locals {
  name_prefix = "${var.project}-${var.environment}"
  is_prod     = var.environment == "prod"
  common_tags = {
    project     = var.project
    environment = var.environment
    owner       = var.team
  }
  public_services = { for k, s in var.services : k => s if s.public }
  azs             = slice(data.aws_availability_zones.available.names, 0, local.is_prod ? 3 : 2)
}

Variables are inputs from outside; locals are internal derivations. If a value shouldn't be overridden by callers, make it a local.

💡 Example: one naming convention everywhere
hcl
locals {
  name_prefix = "petpal-${var.env}"
  common_tags = {
    project = "petpal"
    env     = var.env
    owner   = "platform"
  }
}

resource "aws_s3_bucket" "uploads" {
  bucket = "${local.name_prefix}-uploads"   # petpal-prod-uploads
  tags   = local.common_tags
}

4Outputs

Outputs expose values to the CLI, to calling modules, and to other configurations:

hcl
output "alb_dns_name" {
  description = "Public DNS name of the load balancer"
  value       = aws_lb.main.dns_name
}

output "db_endpoint" {
  value     = aws_db_instance.main.endpoint
  sensitive = true       # hidden in CLI output; still in state
}

output "service_urls" {
  value = { for k, s in local.public_services : k => "https://${k}.${var.domain}" }
}

terraform output -json makes them consumable by scripts and CI (e.g. pass the ALB name to a deploy job).

5Useful Patterns

  • Environment maps: lookup(local.sizes, var.environment) with local.sizes = { dev = "t4g.small", prod = "m7g.large" }.
  • Feature toggles: count = var.enable_bastion ? 1 : 0 (and reference with one(aws_instance.bastion[*].id)).
  • Preconditions/postconditions on resources and outputs to assert assumptions:
hcl
resource "aws_instance" "web" {
  # ...
  lifecycle {
    precondition {
      condition     = data.aws_ami.app.architecture == "arm64"
      error_message = "The selected AMI must be arm64 for Graviton instances."
    }
  }
}
Under the HoodEvaluation and Unknown Values▾

Terraform evaluates expressions while walking the dependency graph. Variables are known at the start; locals and resource attributes become known as their dependencies are evaluated. Some values are only known after apply (a generated ID, an IP assigned by AWS). In a plan these propagate as unknown: "arn:aws:s3:::${aws_s3_bucket.x.id}" is unknown if the bucket doesn't exist yet. Unknowns matter in two places: count/for_each must be known at plan time (Terraform must know how many instances to plan — "Invalid for_each argument… depends on resource attributes that cannot be determined until apply"), and provider configuration can't depend on unknown values. The fix is to drive count/for_each from variables or known data (e.g. a map of names) rather than from attributes of resources created in the same run.

Values flow from defaults, environment variables, tfvars files and -var flags into typed, validated variables; locals derive names, tags and filtered maps; resources consume them; outputs expose results to the CLI, parent modules and other configurations
Values flow from defaults, environment variables, tfvars files and -var flags into typed, validated variables; locals derive names, tags and filtered maps; resources consume them; outputs expose results to the CLI, parent modules and other configurations

⚠️ Common Pitfalls

  1. Untyped any variables — errors surface deep inside resources.
  2. Secrets in tfvars committed to Git.
  3. Believing sensitive encrypts state.
  4. for_each over values unknown until apply.
  5. Variables for things callers should never change — use locals.
  6. No descriptions — modules become guesswork for users.

📋 Quick Reference

text
variable { type, default, description, validation, sensitive, nullable } · optional(T, default) in objects
precedence: default < TF_VAR_ < terraform.tfvars < *.auto.tfvars < -var-file < -var
locals { name_prefix, tags, derived maps } · output { value, description, sensitive } · terraform output -json
precondition / postcondition for assumptions · count/for_each must be known at plan time

🧠 Key takeaways

  • Declare variables with types, descriptions, validation and sensitive = true where needed.
  • Precedence (low → high): default < TF_VAR_ env < terraform.tfvars < *.auto.tfvars < -var-file / -var.
  • Locals remove repetition (naming, tags, derived values); outputs expose values to users and other units.
  • Values unknown until apply show as (known after apply); they can't drive count/for_each keys.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What are variables, locals and outputs?core▾

Variables are inputs (with types, defaults, validation). Locals are named expressions computed inside a module to avoid repetition. Outputs expose values to callers, other stacks, or the CLI.

Follow-ups interviewers ask
  • Where do variable values come from? tfvars files, -var, TF_VAR_ env vars, or defaults.
Q2How do you validate variables?core▾

With type constraints (object({...}), list(string)) and validation blocks with conditions and error messages, e.g. restricting environment to dev/staging/prod.

Follow-ups interviewers ask
  • Optional object attributes? optional(type, default) in object types.
Q3How do you manage per-environment values?senior▾

Separate tfvars files per environment, separate root modules/directories per environment calling shared modules, or workspaces for identical environments.

Follow-ups interviewers ask
  • Which scales best? Separate root configurations per environment with shared modules.
Q4How do you handle sensitive variables?core▾

Mark them sensitive = true, pass them from CI secrets or a secrets manager data source, never commit them in tfvars.

Follow-ups interviewers ask
  • Ephemeral values? Newer Terraform supports ephemeral resources/variables not stored in state.
Q5When should something be a local instead of a variable?core▾

When it's derived from other values or fixed conventions (naming prefixes, common tags) that callers shouldn't override.

Follow-ups interviewers ask
  • Common local? A tags map merged into every resource.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. TF_VAR_x is set and terraform.tfvars sets x too. Which wins?
Environment variables are low in precedence.
2. What's the best use of locals?
Define once, reference everywhere.
3. Marking an output sensitive…
Protect state separately.
4. Why can't for_each keys come from a resource ID created in the same apply?
Keys must be known to build the graph.