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. terraform prompts with this when the variable is unset.
  • sensitive = true: Terraform won't print the value in plan/apply output.
  • nullable = false: disallow null.
  • 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.