OnCallReady

Lesson 12.5 · Terraform: Language & Workflow · 28 min read

Variables, locals and outputs

In plain words

Think of a pizza order form. The form has blank boxes (size, toppings), some with a pre-filled suggestion. The kitchen does a bit of maths of its own ("large pizza means 8 slices") and, at the end, hands you a receipt with the order number.

In Terraform, the blank boxes are variable blocks (inputs, with an optional default), the kitchen's own maths are locals (computed inside, not settable from outside), and the receipt is output blocks. Values come from defaults, TF_VAR_ environment variables, terraform.tfvars, *.auto.tfvars, and -var / -var-file on the command line, in rising order of priority. sensitive = true hides a value on screen, but it still sits in state.

Variables are the configuration's inputs

What you need to know already: resource blocks and references (12.1), provider blocks (12.3), environment variables and export (6.1), shell quoting (6.6), jq (7.11).

A configuration with hard-coded names can only ever build one thing. To build dev and prod from the same files, the parts that differ (names, sizes, region) must come from outside. That is what variables are for - like function parameters, or props on a React component.

variable "env" {
  type        = string
  description = "Target environment: dev, test or prod."

  validation {
    condition     = contains(["dev", "test", "prod"], var.env)
    error_message = "env must be dev, test or prod."
  }
}

variable "node_count" {
  type    = number
  default = 3
}

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

Three input variables: env (a text value that must be one of three words), node_count (a number, 3 unless someone says otherwise), and db_password (a secret, hidden in output).

Everything a variable block can hold:

type         a type constraint; values are converted to it or rejected
default      makes the variable optional; without it the variable is required
description  shown by tools and in module docs - always write it
validation   one or more condition + error_message rules
sensitive    hide the value in plan/apply output (it is still in state)
nullable     false = the caller may not pass null (null means "use the default")
ephemeral    (1.10+) the value is never written to state or plan files

Inside the configuration you read a variable as var.<name>, e.g. var.env. A variable cannot reference another variable in its default - defaults must be literal values. (Validation conditions can refer to other variables since Terraform 1.9.)

Giving a variable a value

Five ways, and you will meet all of them:

# 1. the default in the variable block

# 2. an environment variable - TF_VAR_ followed by the exact name
export TF_VAR_env=dev

# 3. terraform.tfvars (or terraform.tfvars.json) - loaded automatically
env        = "dev"
node_count = 2

# 4. any *.auto.tfvars / *.auto.tfvars.json - loaded automatically, alphabetically

# 5. on the command line, in order
terraform plan -var 'env=prod' -var-file=envs/prod.tfvars

Complex values (lists [...] and maps {...} - next lesson) on the command line are HCL expressions, so wrap them in single quotes for the shell (6.6):

terraform plan -var 'subnets={ app = "10.0.1.0/24", db = "10.0.2.0/24" }'
terraform plan -var 'zones=["1","2","3"]'
export TF_VAR_tags='{ owner = "platform" }'

Precedence, lowest to highest

When the same variable is set in several places, the higher one in this list wins:

1. default in the variable block
2. TF_VAR_name           environment variable   <- the WEAKEST real source
3. terraform.tfvars      (then terraform.tfvars.json)
4. *.auto.tfvars         in lexical order of file name
5. -var and -var-file    in the order given on the command line; the last wins

Two ways people get this wrong, and how the symptoms look:

When a value is not what you expect, walk the list from the bottom up: what does the command line pass, then which .auto.tfvars exist, then terraform.tfvars, then the environment.

Within one level the last writer wins: -var env=dev -var env=prod gives prod. And a later -var-file overrides an earlier -var for the same name - it is strictly command-line order, not "files before flags".

A required variable with no value

A variable with no default is required. Run interactively without a value and Terraform stops and asks:

# the vars mission's configuration: env has no default
terraform plan
var.env
  Target environment: dev, test or prod.

  Enter a value:

In automation (a CI job, a script) nobody is there to type, and that prompt would hang the job forever. So automation runs with -input=false ("never ask") or TF_INPUT=0, and a missing value becomes an error. The lab always behaves like -input=false:

╷
│ Error: No value for required variable
│
│   on variables.tf line 1:
│    1: variable "env" {
│
│ The root module input variable "env" is not set, and has no default value.
│ Use a -var or -var-file command line argument to provide a value for this
│ variable.
╵

("root module" = the directory you run terraform in.)

Validation

A validation rule turns a typo into a clear error before anything is created:

# the vars mission's configuration, with its validation block
terraform plan -var env=banana
╷
│ Error: Invalid value for variable
│
│   on variables.tf line 1:
│    1: variable "env" {
│
│ env must be dev, test or prod.
│
│ This was checked by the validation rule at variables.tf:5,3-13.
╵

The condition is an expression that must be true; if it is false, Terraform prints your error_message. Good conditions to know by heart (the functions are covered in 12.14; read them as English for now):

condition = contains(["dev", "test", "prod"], var.env)               # one of a list
condition = can(regex("^[a-z0-9]{3,24}$", var.storage_name))         # matches a regex (7.3)
condition = var.node_count >= 1 && var.node_count <= 10              # a range
condition = can(cidrhost(var.vnet_cidr, 0))                          # a valid CIDR (8.3)
condition = alltrue([for s in var.subnets : can(cidrhost(s, 0))])    # every element valid
condition = length(var.name) <= 24                                   # length limit

can(...) is true if the expression inside works and false if it errors - the standard way to turn "is this valid?" into a yes/no. The storage-name rule is Azure's: storage account names are 3-24 lowercase letters and digits.

Write error_message as an instruction to the person who hit it: what is allowed, not "invalid input". Write it as a full sentence; the message is printed word for word in the middle of the error block.

Sensitive values

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

sensitive = true hides the value in plan and apply output:

  + administrator_login_password = (sensitive value)

What it does not do, and what the exam loves to ask:

The only real fix for secrets is to keep them out of Terraform: have the application read them from a secret store (like Azure Key Vault, 12.3) when it starts, use managed identities instead of passwords, and (1.10+) mark values ephemeral so they never reach state at all. Ch 14 builds that.

Locals

variable = an input from outside. local = a value computed inside, given a name so you do not repeat the expression - like a const in a function.

locals {
  prefix = "${var.project}-${var.env}"

  common_tags = {
    environment = var.env
    project     = var.project
    managed_by  = "terraform"
  }

  is_prod = var.env == "prod"
}

resource "azurerm_resource_group" "main" {
  name     = "rg-${local.prefix}"
  location = var.location
  tags     = local.common_tags
}

Outputs

An output is a value the configuration prints and remembers after an apply - like a function's return value.

output "resource_group_id" {
  description = "ID of the platform resource group."
  value       = azurerm_resource_group.main.id
}

output "storage_key" {
  value     = azurerm_storage_account.exports.primary_access_key
  sensitive = true
}

.id is an attribute the provider fills in after creating the resource (you did not write it; Azure made it up). primary_access_key is the storage account's password, hence sensitive.

Outputs are how a configuration hands values to a human, to a script, or to another configuration. After an apply:

# a configuration with these two outputs, after an apply
terraform output
resource_group_id = "/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/rg-sysop-dev"
storage_key = <sensitive>

terraform output -raw resource_group_id
/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/rg-sysop-dev

terraform output -json | jq -r .resource_group_id.value
/subscriptions/00000000-1111-2222-3333-444444444444/resourceGroups/rg-sysop-dev
╷
│ Error: Output "vnet_id" not found
│
│ The output variable requested could not be found in the state file. If you
│ recently added this output to your configuration, be sure to run `terraform
│ apply`, since the state won't be updated with new output variables until
│ that command is run.
╵

Later (Ch 13): a reusable module's outputs are its return values (module.network.vnet_id); the root only prints its own, so you re-export a module's value to see it.

Putting it together

A typical small configuration, split into files the conventional way:

# variables.tf
variable "env" {
  type = string
  validation {
    condition     = contains(["dev", "test", "prod"], var.env)
    error_message = "env must be dev, test or prod."
  }
}

variable "location" {
  type    = string
  default = "westeurope"
}

# main.tf
locals {
  prefix = "sysop-${var.env}"
}

resource "azurerm_resource_group" "main" {
  name     = "rg-${local.prefix}"
  location = var.location
}

# outputs.tf
output "resource_group_id" {
  value = azurerm_resource_group.main.id
}

# terraform.tfvars
env = "dev"

terraform plan uses dev from the tfvars file; terraform plan -var env=prod overrides it; terraform plan -var env=banana never gets past validation.

What you can now do:

Why it helps

The classic confusion you will debug for someone: "I exported TF_VAR_env=prod and it still plans dev." The answer is precedence: terraform.tfvars beats the environment, and the pipeline's -var-file beats both. Knowing the order bottom-up turns a 30-minute mystery into a one-minute check. In PR review you will insist on description, validation blocks and sensitive on anything secret, and you will catch outputs leaking secrets through terraform output -json in CI logs. The exam asks precedence and "does sensitive keep values out of state?" (no) almost every sitting.

FAQ

What is the precedence order for variable values?

Lowest to highest: the default, then TF_VAR_name environment variables, then terraform.tfvars (and .tfvars.json), then *.auto.tfvars files in lexical order, then -var and -var-file in the order they appear on the command line, last one winning. So the environment is the weakest real source, and a later -var-file overrides an earlier -var.

Does sensitive = true encrypt the value or keep it out of state?

Neither. It only redacts the value in plan and apply output ((sensitive value)). The state still stores it in plaintext JSON, and terraform output -json prints it in clear. It also spreads: anything derived from a sensitive value is sensitive, and an output exposing it must be marked sensitive = true too. Real protection means protected state storage and keeping secrets out of Terraform (the app reads them from a vault at runtime, managed identities, ephemeral on 1.10+).

What is the difference between a variable and a local?

A variable is an input from outside the module: the caller, a tfvars file, the command line. A local is a named value computed inside the module from variables, resources and other locals, and nobody outside can set it. Use locals for naming prefixes, common tag maps and derived booleans like is_prod. The block is locals (plural), the reference is local.name (singular).

Why does my pipeline fail with "No value for required variable" when locally it prompts me?

Locally Terraform asks interactively for a required variable without a value. Pipelines run with -input=false (or TF_INPUT=0) so a prompt cannot hang the job, and the missing value becomes an error. The fix is to supply it: a -var-file for the environment, a TF_VAR_ variable from the CI system's secret settings, or a sensible default if it genuinely has one.

Why does terraform output say my new output does not exist?

Outputs are read from state, and state is only written by apply. A newly added output block shows "Output not found" until an apply saves it; terraform apply -refresh-only is a way to record it without changing resources. Use -raw for a bare string in shell scripts and -json for machines, remembering that -json includes sensitive values in clear text.

In an interview Junior

How do you pass different values to the same Terraform code for dev and prod?

Declare input variables and give them values from outside:

variable "env" {
  type = string
  validation {
    condition     = contains(["dev", "test", "prod"], var.env)
    error_message = "env must be dev, test or prod."
  }
}

Then one .tfvars file per environment: terraform plan -var-file=prod.tfvars. Other ways: -var 'env=prod', TF_VAR_env, terraform.tfvars and *.auto.tfvars (read automatically), and the default.

Precedence, lowest to highest: default, TF_VAR_ environment variables, terraform.tfvars, *.auto.tfvars, then -var / -var-file in command-line order. When a value is not what you expect, walk that list from the top.

Inside the code use var.env; build derived values once in locals; expose results with output. A secret variable gets sensitive = true - which hides it in output but not in state.

Also asked: A variable is marked sensitive. Is its value safe? · What is the difference between a variable and a local? · What happens when a required variable has no value in a CI job?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.