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
-var 'name=value'sets one variable for this run.-var-file=FILEreads a file of values.- A
.tfvarsfile is plainname = valuelines - no blocks, novar.prefix. Onlyterraform.tfvarsand*.auto.tfvarsare read automatically; any other name (prod.tfvars,dev.tfvars) is only read when you pass it with-var-file.
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:
- "I exported
TF_VAR_env=prodand it still plans dev" - aterraform.tfvarssetsenv, and tfvars beats the environment. - "I changed
terraform.tfvarsand nothing happened" - the CI job passes-var-file=envs/dev.tfvars, which beats it.
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:
- It does not keep the value out of state. State (the JSON file Terraform keeps, 12.1) holds every attribute in plain text. Protect the state (Ch 13).
- It does not encrypt anything, and it does not stop you writing the value to a file with a
local_fileresource. - It spreads: any expression built from a sensitive value is sensitive too. An output that shows it must itself say
sensitive = true, or plan fails with "Output refers to sensitive values".
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
}
"${var.project}-${var.env}"is string interpolation, like a JS template literal:sysopanddevbecome"sysop-dev".common_tagsis a map (key = value pairs), used as the resource's tags.is_prodis a bool (true/false).- The block is
locals(plural); the reference islocal.<name>(singular). That trips everyone once. - Locals can reference variables, resources, data sources and other locals. They cannot be set from outside - which is the point.
- Use them for names, tag maps and derived booleans. Do not turn every literal into a local; a value used once is clearer inline.
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
terraform outputlists all outputs; sensitive ones show<sensitive>.terraform output -raw NAMEprints one string with no quotes and no newline - for shell variables:rg=$(terraform output -raw resource_group_id).terraform output -jsonprints everything as JSON, including sensitive values in clear text - it is meant for machines (jq), so be careful where you log it.- Outputs are read from state. A new output block shows nothing until an apply has saved it:
╷
│ 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:
- Declare inputs with types, defaults, validation and
sensitive. - Set them five ways and predict which one wins.
- Name repeated expressions with
localsand expose results withoutput.