Terraform & Infrastructure as Code›03 · Variables, outputs & data sources
Learning Hub / Delivery & Infrastructure as Code / Terraform & Infrastructure as Code

Lesson 03 of 7 · Modules

Variables, outputs & data sources

Give configurations clean interfaces: typed variables with validation, locals for derived values, outputs for consumers, data sources to read what already exists, and the precedence rules for where values come from.

Practitioner
Key wordsvariablestypesvalidationsensitivelocalsoutputsdata sourcestfvars precedencefor_each

Configurations need interfaces

A good Terraform configuration or module is like a function: inputs (variables), internal values (locals), outputs, and it can look things up (data sources).

Think of a vending machine. Variables are the buttons you press (which drink, what size). Locals are the machine's inside workings. Outputs are what comes out of the tray. Data sources are the machine checking how much stock it already has, without making new drinks.

Variables with types and validation

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

variable "buckets" {
  type = map(object({
    versioning = optional(bool, true)
    expire_days = optional(number)
  }))
  default = {}
}

variable "db_password" {
  type      = string
  sensitive = true          # hidden in CLI output (still stored in state!)
}

Types (string, number, bool, list(...), map(...), object({...}), with optional() defaults) turn typos into early, clear errors.

Locals: name derived values once

locals {
  name_prefix = "acme-${var.environment}"
  common_tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    Owner       = "platform-team"
  }
}

for_each: many similar resources

resource "aws_s3_bucket" "this" {
  for_each = var.buckets
  bucket   = "${local.name_prefix}-${each.key}"
  tags     = local.common_tags
}

Resources are addressed as aws_s3_bucket.this["logs"], aws_s3_bucket.this["artifacts"]. Removing logs leaves artifacts untouched. With count, removing index 0 would shift everything and trigger replacements.

Data sources: read, don't own

data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]           # Canonical
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
  }
}

Data sources are read at plan time. Beware most_recent = true in production: a new AMI changes the plan by itself. Pin versions when you need stability.

Outputs: the configuration's public interface

output "bucket_names" {
  description = "Buckets created, by key"
  value       = { for k, b in aws_s3_bucket.this : k => b.bucket }
}

Outputs are what other layers, modules and CI read. Keep them deliberate and documented.

Where values come from

From lowest to highest precedence: variable defaults → TF_VAR_ environment variables → terraform.tfvars → *.auto.tfvars (alphabetical) → -var-file / -var on the command line (later ones win). Keep one explicit -var-file per environment for clarity.

Try it: a typed, validated interface (free)

Using only the local and random providers:

  1. Add a validated environment variable, and a map(object) variable describing files to create (name → { content, mode = optional(string, "0644") }).
  2. Create the files with for_each; output a map of filename → path.
  3. Try an invalid environment and read the validation error.
  4. Remove one entry from the map and confirm only that file is destroyed. Then rewrite it with count over a list, remove the first item, and compare the plans.
  5. Set the same variable via TF_VAR_, terraform.tfvars and -var; which wins?

Going deeper: interface design

  • Prefer a few well-typed objects over dozens of loose variables; add description to every variable and output (tools generate docs from them).
  • Don't expose every provider argument. A module's value is in the decisions it makes for callers.
  • Sensitive values: pass them from a secret store (data source or CI secret), never commit them in tfvars.
  • Use precondition/postcondition blocks in resources to assert assumptions (e.g. "the AMI must be arm64").

Recap

  • Variables with types, defaults, validation and sensitive; locals for derived values; outputs as the public interface.
  • for_each over maps/sets for stable addresses (prefer it over count for named items).
  • Data sources read existing things without owning them; pin what must be stable.
  • Know the precedence: defaults < env < tfvars < auto.tfvars < command line.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.