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.
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:
- Add a validated
environmentvariable, and amap(object)variable describing files to create (name → { content, mode = optional(string, "0644") }). - Create the files with
for_each; output a map of filename → path. - Try an invalid environment and read the validation error.
- Remove one entry from the map and confirm only that file is destroyed. Then rewrite it with
countover a list, remove the first item, and compare the plans. - Set the same variable via
TF_VAR_,terraform.tfvarsand-var; which wins?
Going deeper: interface design
- Prefer a few well-typed objects over dozens of loose variables; add
descriptionto 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/postconditionblocks 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.