Variables, Outputs & Data Sources
Why this matters
Everything you've written so far hard-codes names, regions, and CIDR ranges directly in resource blocks. That doesn't scale: you can't reuse a config for dev and prod, you can't cleanly hand off a value like a cluster's FQDN to whoever needs it, and you can't reference the resources other teams already built. Variables, outputs, locals, and data sources fix all four — they're what turn a one-off script into reusable, composable infrastructure code, and they're prerequisites for writing modules (module 04).
Concepts
Input variables
A variable block declares a parameter your config accepts, so callers can
change behavior without editing resource blocks:
variable "location" {
type = string
default = "eastus"
description = "Azure region for all resources"
}
You reference it as var.location. Key fields:
type—string,number,bool, or complex types (list(string),map(string),object({...})). Terraform enforces the type.default— makes the variable optional. No default = required; if the caller doesn't supply it, Terraform prompts (interactively) or errors (in automation).description— documents it (shows up in errors and tooling).sensitive = true— redacts the value from plan/apply output (use for anything secret; note it's still stored in state).validation { ... }— a custom rule (e.g. "must be one of these regions") that rejects bad input at plan time.
How variable values get supplied
Terraform resolves a variable's value from the first source that provides it, in this precedence (highest wins):
-varon the command line:terraform apply -var="location=westus".-var-file:terraform apply -var-file=prod.tfvars.- Automatically loaded
terraform.tfvarsand*.auto.tfvarsfiles. - Environment variables named
TF_VAR_<name>(e.g.TF_VAR_location). - The
defaultin thevariableblock.
The everyday pattern: declare variables with sensible defaults, and put
per-environment overrides in a dev.tfvars / prod.tfvars file you pass
with -var-file. Never put secrets in a .tfvars file you commit —
use TF_VAR_ env vars or a secrets manager for those.
Locals: named expressions, not inputs
A locals block names a computed value used in multiple places. Unlike a
variable, a local is not an input — the caller can't override it; it's
internal DRY-ing:
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
environment = var.environment
managed_by = "terraform"
project = var.project
}
}
Reference them as local.name_prefix, local.common_tags. Use variables
for things a caller should control; use locals for values you derive from
variables (or from resource attributes) and want to reuse.
Output values
An output block surfaces a value after apply — on the terminal, and (more
importantly) for other configurations/modules to consume:
output "resource_group_name" {
value = azurerm_resource_group.main.name
description = "The name of the created resource group"
}
Outputs are how a module hands results back to its caller (module 04) and
how you programmatically grab a value (terraform output -raw resource_group_name) to feed into a script or a kubectl command. Mark an
output sensitive = true to keep it out of console logs.
Data sources: referencing what you didn't create
A resource block creates and manages something. A data block
reads something that already exists — whether another team made it, you
made it in the portal, or another Terraform config owns it:
data "azurerm_resource_group" "existing" {
name = "rg-shared-networking"
}
You reference its attributes as
data.azurerm_resource_group.existing.location. Terraform does not manage
or destroy a data source — it only queries it during refresh. This is the
clean way to deploy into a shared VNet another team owns without taking
ownership of it: read it with a data block, reference its ID, and let the
other team's Terraform keep managing it. In a plan, data sources show as
<= (read).
Variable validation
A validation block catches bad input before any API call:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
This turns a class of runtime failures (typo'd environment name → wrongly named resources) into an immediate, readable plan-time error.
Command reference
| Command | What it does | Example |
|---|---|---|
terraform plan -var |
Sets one variable inline | terraform plan -var="location=westus" |
terraform apply -var-file |
Loads variables from a file | terraform apply -var-file=prod.tfvars |
terraform output |
Prints all output values | terraform output |
terraform output <name> |
Prints one output | terraform output resource_group_name |
terraform output -raw <name> |
Prints one output with no quotes/formatting (for scripts) | terraform output -raw kube_config |
terraform output -json |
Prints all outputs as JSON | terraform output -json |
terraform console |
Interactive expression evaluator (test local./var. expressions) |
terraform console |
terraform plan -var-file |
Plans with a var file | terraform plan -var-file=dev.tfvars |
Flag breakdown — terraform apply -var-file=prod.tfvars:
-var-file=<file>— loads variable assignments from the named file. Pass it explicitly for files not auto-loaded (anything not namedterraform.tfvarsor*.auto.tfvars). This is how you keep dev and prod values in separate, reviewable files.
Flag breakdown — terraform output -raw <name>:
-raw— prints the value with no surrounding quotes or HCL formatting, so it's safe to embed directly in a shell command, e.g.az aks get-credentials -g "$(terraform output -raw rg_name)" .... Only works for string/number outputs.
Hands-on exercises
Free resources again (RG + VNet + subnet). Destroy at the end.
Set up with variables. New directory:
mkdir -p ~/tf-labs/03-variables && cd ~/tf-labs/03-variables export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"Create
variables.tf:variable "project" { type = string description = "Short project slug used in resource names" default = "tflab" } variable "location" { type = string description = "Azure region" default = "eastus" } variable "environment" { type = string description = "Deployment environment" validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be dev, staging, or prod." } }Note
environmenthas no default — it's required.Add locals and resources that use them. Create
main.tfwith the provider skeleton, then:locals { name_prefix = "${var.project}-${var.environment}" common_tags = { environment = var.environment managed_by = "terraform" } } resource "azurerm_resource_group" "main" { name = "rg-${local.name_prefix}" location = var.location tags = local.common_tags } resource "azurerm_virtual_network" "hub" { name = "${local.name_prefix}-vnet" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location address_space = ["10.0.0.0/16"] tags = local.common_tags }Watch validation reject bad input. Try an invalid environment:
terraform init terraform plan -var="environment=production"Verify: it fails immediately with your custom message (
environment must be dev, staging, or prod.) — no Azure call made. Now runterraform plan -var="environment=dev"and confirm it plans2 to add, with names likerg-tflab-dev.Use a tfvars file instead of
-var. Createdev.tfvars:environment = "dev" location = "eastus"Then:
terraform apply -var-file=dev.tfvarsVerify: resources are created with
-devin their names. Confirm withaz group show -n rg-tflab-dev -o table.Add outputs. Create
outputs.tf:output "resource_group_name" { value = azurerm_resource_group.main.name description = "Name of the created resource group" } output "vnet_id" { value = azurerm_virtual_network.hub.id description = "Full Azure resource ID of the VNet" }Run
terraform apply -var-file=dev.tfvarsagain, then:terraform output terraform output -raw resource_group_nameVerify:
terraform outputlists both;-rawprints just the group name with no quotes — the form you'd pipe into another command.Reference an output from a real command. Prove outputs are useful for scripting:
az group show -n "$(terraform output -raw resource_group_name)" -o tableVerify: it shows your resource group, fetched by feeding Terraform's output straight into
az.Add a data source for an existing resource. First, create something outside Terraform with
azto read back:az group create -n rg-preexisting-lab -l eastusAdd to
main.tf:data "azurerm_resource_group" "external" { name = "rg-preexisting-lab" } output "external_rg_location" { value = data.azurerm_resource_group.external.location }Run
terraform plan -var-file=dev.tfvars.Verify: the data source shows as a read (
<=), not a create, and the plan adds only the new output — Terraform readsrg-preexisting-labbut does not try to create or manage it. Apply, thenterraform output external_rg_locationprintseastus.Diagnose and fix: a data source that doesn't exist. Point the data source at a non-existent group:
data "azurerm_resource_group" "external" { name = "rg-does-not-exist-xyz" }Run
terraform plan -var-file=dev.tfvars.Verify: it errors during refresh with something like
Resource group "rg-does-not-exist-xyz" was not found. A data source must reference something that actually exists — unlike a resource, Terraform won't create it. Fix by pointing it back atrg-preexisting-lab.Experiment in the console. Test expressions without applying:
terraform console -var-file=dev.tfvarsAt the prompt type
local.name_prefixandlocal.common_tags, thenexit.Verify: it evaluates them to
tflab-devand the tag map — a fast way to debug expressions before they hit a resource.Clean up both groups. The data-source group isn't managed by Terraform, so
destroywon't remove it — delete it separately:terraform destroy -var-file=dev.tfvars az group delete -n rg-preexisting-lab --yes --no-waitVerify:
terraform destroyremoves only the resources it manages (the-devgroup and its VNet), and leavesrg-preexisting-labuntouched — which is exactly why you delete that one by hand. Confirm both are gone withaz group list -o table.
Independent challenge
Without copying the exercises, write a configuration that is fully
parameterized for two environments: it should build a resource group
and a VNet whose names, region, and address space all come from variables,
with a dev.tfvars and a prod.tfvars that put dev in one region with
10.0.0.0/16 and prod in another region with 10.1.0.0/16. Deploy both
(they must not collide — different names), then use outputs to print each
VNet's ID, and finally destroy both. As a stretch, add a data source that
reads the dev resource group from within the prod config to prove one
config can reference another's output. This builds on this module's
variables/outputs/data-sources and track 5's non-overlapping-address-space
discipline.
Stuck? One hint
To run two environments from one config directory without them clobbering
each other's state, either use two separate directories each with its own
.tfvars, or use terraform workspace new prod / terraform workspace select dev so each environment gets its own state file. The simplest,
clearest approach for learning is two directories. Make sure every resource
name derives from var.environment so dev and prod names never
collide.
Common mistakes & troubleshooting
- Putting secrets in a committed
.tfvarsfile..tfvarsfiles are plaintext and often committed. UseTF_VAR_<name>env vars or a secrets manager for anything sensitive, and.gitignoreany*.tfvarsholding secrets. - Expecting a data source to create the resource.
datablocks only read. If the target doesn't exist, refresh fails — data sources are for things that already exist,resourceblocks are for things you own. - Confusing variables and locals. Variables are caller-supplied inputs (overridable); locals are internally-derived values (not overridable). If a caller should control it, it's a variable.
- Forgetting a required variable in automation. A variable with no
defaultprompts interactively — which hangs a CI job. Supply every required variable via-var-file/TF_VAR_in automation. - Marking something
sensitiveand thinking it's now safe in state.sensitive = trueonly redacts console output; the value is still stored in plaintext in state. Protect state itself (module 05). - Cost pitfall:
terraform destroyonly removes resources Terraform manages — anything you created withaz(like the data-source group here) survives and keeps billing if it's a paid resource. Track out-of-band resources yourself and delete them explicitly.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What makes a variable required versus optional?
- Rank these value sources by precedence (which wins): a
default, a-varflag, aterraform.tfvarsfile, aTF_VAR_env var. - What's the difference between a
variableand alocal? - What does an
outputblock do, and name two things you'd use one for. - What's the fundamental difference between a
resourceblock and adatablock? - In a plan, what symbol marks a data source, and does Terraform ever destroy what a data source reads?
- You need a cluster password never to appear in
terraform planoutput. What do you set — and what's the important caveat about state? terraform destroyran cleanly, but a resource you created earlier withazis still there and billing. Why, and whose job is it to remove it?
Show answers
- A variable is required if it has no
default. With no default and no supplied value, Terraform prompts (interactively) or errors (in automation). - Highest to lowest:
-varflag >terraform.tfvarsfile (auto-loaded) >TF_VAR_env var >default. (A-var-filesits with the file tier, above env vars.) - A variable is a caller-supplied input that can be overridden; a local is an internally-computed named value that callers cannot override — used to DRY up derived expressions.
- It surfaces a value after apply, on the console and for other
modules/configs to consume. Uses: handing a result to a calling module,
or grabbing a value (
-raw) to feed into a script/az/kubectl. - A
resourcecreates and manages something (Terraform owns its lifecycle); adatablock only reads an existing thing Terraform does not own or destroy. <=(read). No — Terraform never creates or destroys what a data source references; it only queries it.sensitive = trueon the variable/output. Caveat: it only redacts console output — the value is still stored in plaintext in the state file, so you must also protect state itself.terraform destroyonly removes resources in its state (things Terraform manages). Theaz-created resource was never managed by Terraform, so it's your responsibility to delete it separately.
Next
04 — Modules & code organization: package a reusable chunk of infrastructure with its own inputs and outputs, and stop copy-pasting the same resource blocks between projects.