Variables and Outputs: Making Configs Reusable
This chapter covers input variables, outputs, locals, and the type system that separates a hardcoded prototype from a reusable configuration.
The Three Kinds of Named Values
Terraform has three places to put named values. Each has a purpose.
variable: inputs to a configuration. Set by the caller (CLI, tfvars, env var, module argument).local: computed inside a configuration. Never set from outside.output: values exposed to the outside (to CLI output, or to a parent module).
Use them in that order of preference: variables for things the caller should control, locals for things you compute once, outputs for things consumers need.
Variables
A variable has a declaration and (optionally) a value.
Declaration
variable "region" {
type = string
default = "us-east-1"
description = "AWS region for resources."
}
Optional fields:
default: a fallback if the caller doesn't set it.description: documentation. Do it.terraformprompts with this when the variable is unset.sensitive = true: Terraform won't print the value in plan/apply output.nullable = false: disallownull.validation: custom rules (more below).
Without a default, the variable is required. Terraform will ask for it interactively or fail in CI.
Setting Values
Precedence, highest wins:
1. -var CLI flag: terraform apply -var="region=eu-west-1"
2. -var-file CLI flag: terraform apply -var-file=prod.tfvars
3. *.auto.tfvars files: auto-loaded, alphabetical
4. terraform.tfvars[.json]: auto-loaded by default
5. Environment variables: TF_VAR_region=eu-west-1 terraform apply
6. Default in variable block
7. Interactive prompt
Practical rule: put per-env values in environments/prod.tfvars and pass with -var-file. Don't rely on precedence gymnastics.
Example terraform.tfvars:
region = "us-east-1"
environment = "prod"
instance_count = 3
Types
Terraform has a real type system. Declare types explicitly; future-you will thank present-you.
variable "region" {
type = string
}
variable "instance_count" {
type = number
}
variable "enabled" {
type = bool
}
variable "zones" {
type = list(string)
}
variable "tags" {
type = map(string)
}
variable "subnet_cidrs" {
type = set(string)
}
variable "server" {
type = object({
name = string
instance_type = string
disk_size = number
})
}
variable "servers" {
type = list(object({
name = string
instance_type = string
}))
}
object is a struct: named fields with types. tuple is a fixed-length list of mixed types. Use object for structured config.
Optional Fields in Objects
variable "server" {
type = object({
name = string
instance_type = optional(string, "t3.micro") # default value
disk_size = optional(number) # null if not set
})
}
Great for modules that take complex config with sensible defaults.
Validation
variable "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
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "Instance count must be between 1 and 10."
}
}
Catches typos early. Always validate enums and ranges.
Sensitive Variables
variable "db_password" {
type = string
sensitive = true
}
Terraform won't print the value. It still goes into the state file (see Chapter 5). For real secrets, prefer fetching from a secrets manager with a data source.
Locals
locals blocks define named values computed from other values.
locals {
environment = var.environment
bucket_name = "notes-${var.environment}-${random_id.suffix.hex}"
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = "notes"
}
# Computed conditional
instance_type = var.environment == "prod" ? "m5.large" : "t3.medium"
# Derived map
subnet_cidrs_by_az = {
for az in var.availability_zones :
az => cidrsubnet(var.vpc_cidr, 8, index(var.availability_zones, az))
}
}
Reference a local as local.name (note: singular local., not locals.).
Good uses of locals:
- Computing a tag map once and reusing everywhere.
- Naming things based on variables and derived values.
- Encoding conditional logic in one readable place.
Bad use: defining locals that are used once. Inline them.
Outputs
Outputs expose values. At the top level, they print after apply. In modules, they become accessible via module.name.output_name.
output "bucket_name" {
value = aws_s3_bucket.notes.bucket
description = "Name of the notes bucket."
}
output "bucket_arn" {
value = aws_s3_bucket.notes.arn
}
output "db_connection" {
value = aws_db_instance.main.endpoint
sensitive = true
}
description appears in terraform output and module docs. sensitive = true suppresses value in command output (it's still in state).
Read all outputs:
terraform output
One output:
terraform output bucket_name
In JSON:
terraform output -json
Passing Variables in Practice
Interactive
terraform apply
# var.region
# AWS region for resources.
# Enter a value: us-east-1
Fine for one-offs. Not for CI.
tfvars File
terraform apply -var-file=prod.tfvars
Most common for multi-environment. Chapter 8 covers environment patterns.
Environment Variables
export TF_VAR_region=us-east-1
export TF_VAR_environment=prod
terraform apply
Great for CI, especially for secrets. Terraform reads any TF_VAR_<name> env var.
CLI Flag
terraform apply -var="region=us-east-1"
Handy for quick overrides.
Type Conversion
Terraform auto-converts between compatible types. Sometimes you need to nudge.
toset(["a", "b", "c"]) # list → set
tolist(toset([...])) # set → list
tomap(object_value) # object → map (keys must be strings)
tostring(123) # number → string
tonumber("42") # string → number
tobool("true") # string → bool
You hit this most when going from list to set for for_each.
A Full Example
Bring it together. A reusable notes-bucket config:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "retention_days" {
type = number
default = 30
validation {
condition = var.retention_days >= 1 && var.retention_days <= 365
error_message = "Retention must be between 1 and 365 days."
}
}
locals {
bucket_name = "notes-${var.environment}-${random_id.suffix.hex}"
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = "notes"
}
}
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "notes" {
bucket = local.bucket_name
tags = local.common_tags
}
resource "aws_s3_bucket_lifecycle_configuration" "notes" {
bucket = aws_s3_bucket.notes.id
rule {
id = "retention"
status = "Enabled"
expiration {
days = var.retention_days
}
}
}
output "bucket_name" {
value = aws_s3_bucket.notes.bucket
}
output "bucket_arn" {
value = aws_s3_bucket.notes.arn
}
Apply for dev:
terraform apply -var="environment=dev"
Apply for prod with a longer retention:
terraform apply -var="environment=prod" -var="retention_days=365"
One config, multiple environments. That's the point of variables.
Common Pitfalls
Untyped variables. variable "x" {} without a type defaults to any and accepts anything. Declare types.
No descriptions. Six months from now, variable "size" is a mystery. Always write a description.
Secrets in .tfvars files committed to git. Gitignore them. For secrets, use a secrets manager data source or -var at apply time.
Treating locals as reusable. Locals belong to one configuration. A value used across modules should be passed as a variable.
Overusing outputs. Only output what consumers need. A module that exposes every attribute is a leaky abstraction.
Next Steps
Continue to 04-resources-and-data.md to fill configurations with real infrastructure.