Skip to content

06 · Variables & Outputs

Input variables let a configuration accept values from outside itself; outputs let it expose values to whoever ran it (or to other configurations). Together they're what makes a configuration reusable instead of hardcoded.

Declaring an input variable

variable "environment" {
  description = "Deployment environment name"
  type        = string
  default     = "dev"
}

variable "instance_count" {
  description = "Number of instances to create"
  type        = number
  default     = 1
}

variable "enable_monitoring" {
  description = "Whether to enable detailed monitoring"
  type        = bool
  default     = false
}

type constrains what values are acceptable — passing a string where number is declared fails at plan time with a clear type-mismatch error rather than silently coercing or failing later. description isn't functional but shows up in terraform plan prompts and generated docs, so it's worth writing for every variable.

Complex types

variable "availability_zones" {
  type    = list(string)
  default = ["us-east-1a", "us-east-1b"]
}

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

variable "database_config" {
  type = object({
    engine         = string
    instance_class = string
    storage_gb     = number
  })
  default = {
    engine         = "postgres"
    instance_class = "db.t3.micro"
    storage_gb     = 20
  }
}

object({...}) describes a structured value with named, typed fields — useful for grouping related settings instead of passing five separate flat variables.

Using a variable

Reference it anywhere in the configuration as var.<name>:

resource "aws_instance" "web" {
  count         = var.instance_count
  ami           = "ami-0c94855ba95c71c99"
  instance_type = "t3.micro"

  tags = merge(var.tags, {
    Environment = var.environment
  })
}

Ways to set a variable's value

Terraform resolves variable values from several sources, in increasing order of precedence (later wins over earlier):

# 1. variable default (lowest precedence)
variable "environment" {
  type    = string
  default = "dev"
}
# 2. terraform.tfvars — automatically loaded if present
environment = "staging"
# 3. *.auto.tfvars — also automatically loaded, alphabetically
# prod.auto.tfvars
environment = "prod"
# 4. -var-file on the command line
terraform apply -var-file="prod.tfvars"

# 5. -var on the command line (highest precedence among file-based options)
terraform apply -var="environment=prod"

# 6. TF_VAR_ environment variables (also read automatically, lower than -var)
export TF_VAR_environment=prod
terraform apply

A common pattern: commit a dev.tfvars with safe defaults, keep prod.tfvars reviewed carefully (or generated by CI), and never hardcode the environment name inside main.tf itself.

Marking a variable sensitive

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

sensitive = true tells Terraform to redact the value from plan/apply console output (shown as (sensitive value)) — it does not encrypt it in the state file, which is one reason module 07 flags state files as needing careful handling.

Declaring an output

output "instance_ids" {
  description = "IDs of the created instances"
  value       = aws_instance.web[*].id
}

output "bucket_arn" {
  value = aws_s3_bucket.reports.arn
}

output "db_password" {
  value     = var.db_password
  sensitive = true   # required if the value is (or derives from) something sensitive
}

After terraform apply, outputs print to the console and are queryable later:

terraform output
# bucket_arn = "arn:aws:s3:::acme-reports-2026"
# instance_ids = [
#   "i-0abc123",
# ]

terraform output -json bucket_arn
# "arn:aws:s3:::acme-reports-2026"

terraform output -raw bucket_arn
# arn:aws:s3:::acme-reports-2026   (no surrounding quotes — useful in scripts)

Why outputs matter beyond just printing values

Outputs are also how one Terraform configuration hands values to another — either through terraform_remote_state (Level 2) or by being passed as a module's output (Level 2's modules topic). A configuration that provisions a VPC typically outputs its subnet IDs specifically so a different configuration (managed by a different team, applied separately) can consume them without needing direct access to the VPC's own state.

How It Actually Works: variable precedence and the evaluation graph

Documented mechanics of how Terraform Core resolves a variable's final value and where it fits in the graph — not run against a live workspace here:

  • Variables are graph nodes too. Every variable block becomes a node in the same dependency graph as resources. Anything referencing var.name creates an edge from that resource to the variable node, so the variable's final value must be fully resolved before any dependent resource is planned — this is why a variable can't (directly) depend on a resource attribute; the graph only flows one direction, from inputs toward resources.
  • Precedence is a strict, documented override order, evaluated once per run, highest wins: command-line -var/-var-file flags, then *.auto.tfvars (alphabetically), then explicit -var-file arguments, then a terraform.tfvars file, then TF_VAR_name environment variables, then the default in the variable block itself. Terraform doesn't merge complex-typed values across these sources — whichever source wins for a given variable name supplies the entire value.
  • Type constraints are enforced by conversion, not just validation. When you declare type = list(string), Terraform doesn't just check the input — it attempts to convert the raw value (which may come from environment strings, JSON in a .tfvars.json file, or CLI text) into that exact type, using HCL's type conversion rules, and fails the run at parse-time if conversion isn't possible.
  • Outputs are graph nodes with the widest dependency fan-in. An output block implicitly depends on every resource attribute it references, and — critically — a root module's outputs are recomputed every single apply even when nothing they reference changed, because Terraform re-evaluates output expressions from final state after the apply completes, rather than caching them.
  • sensitive = true is a display flag, not encryption. It only suppresses the value from CLI output and plan summaries; the raw value is still written in plaintext inside the state file (module 07 covers why that matters for state file handling).

Exercise

Write a variable block for a region string with a sensible default, a variable block for a tags map, and an output block that exposes a value merging var.tags with an additional ManagedBy = "Terraform" entry (hint: the merge() function from module 03's expression syntax — full function coverage is in Level 2). Then write the -var command-line invocation that would override region to "eu-west-1" at apply time.