Terraform Lesson 6 of 57

Terraform Variables, Outputs & Locals, In Depth: Types, Validation, Sensitivity & Precedence

Every Terraform configuration that survives past a weekend ends up needing three things: a way to take values in (so the same code can build dev, staging and prod), a way to hand values out (so a VPC’s ID can feed the database stack), and a way to name intermediate values once (so a tagging convention or a computed name isn’t copy-pasted across forty resources). Those three jobs map exactly onto Terraform’s three value-carrying constructs — input variables, outputs, and locals — and getting them right is the difference between a module other people trust and one they quietly fork. This lesson covers all three exhaustively: every argument each block accepts, the complete and frequently-misremembered variable-definition precedence order, how Terraform decides which value wins when the same variable is set five different ways, and the uncomfortable truth about sensitive — what it protects and the several places it does not.

This is a Foundation lesson, so every term is defined as it appears and you need no prior Terraform beyond knowing that a .tf file holds blocks. But it is also written to the depth an interviewer or the Terraform Associate exam will probe: by the end you will be able to reach for optional() object attributes, multi-rule validation blocks, nullable = false, ephemeral inputs and outputs, and terraform_remote_state with confidence, and you will know precisely why a -var on the command line beats a value in terraform.tfvars. Everything here applies identically to OpenTofu, the open-source fork — the HCL and the terraform/tofu CLI behave the same for variables, outputs and locals, with the small modern additions (ephemeral values, write-only behaviour) noted where they matter.

Learning objectives

After working through this lesson you will be able to:

Prerequisites

You need a working command line, a text editor, and Terraform 1.9 or later (or OpenTofu 1.7+); install instructions live in The Terraform CLI, In Depth. The hands-on lab uses only the built-in local and random providers, so no cloud account and no spend are required — everything runs on your laptop. You should already be comfortable with the init → plan → apply → destroy loop and recognise what a resource block looks like; if not, read Terraform Fundamentals: HCL, Providers, State & the Core Workflow first. This lesson sits in the Foundation tier of the Terraform Zero-to-Hero ladder, immediately after resources and meta-arguments, and everything in the modules lessons that follow assumes you can confidently pass variables in and read outputs back.

Core concepts: the three constructs at a glance

Terraform has exactly three top-level blocks whose job is to carry values rather than to create infrastructure. Keep their roles distinct in your head and most confusion evaporates.

Block Direction Set by Read as One-line purpose
variable In (parameter) The caller / CLI / files / env var.<name> Parameterise the configuration so the same code builds many environments.
output Out (return value) The configuration computes it module.<m>.<name> or remote state Expose a result to the operator, to a parent module, or to another stack.
local Internal The configuration computes it local.<name> Name a value (often computed) once and reuse it; the DRY tool.

A useful mental model from ordinary programming: a variable is a function argument, an output is a return value, and a local is a let binding inside the function body. Variables are the only one of the three that an external caller can influence. Outputs are the only one a parent can read. Locals are private to the module they’re declared in — nobody outside can set them or read them.

Two more terms you’ll meet constantly:

Input variables: every option

An input variable is declared with a variable block. The label after variable is the name (referenced as var.<name>). The block body accepts a small, fixed set of meta-arguments — there are no others, so this list is the complete surface:

Argument Type Purpose Default if omitted
type type constraint Restrict and convert the accepted value any (accept anything)
default any value Make the variable optional; supply a fallback none → variable is required
description string Human documentation; surfaces in plan, docs tooling, and the Registry empty
validation block (repeatable) Custom rules with a condition and error_message none
sensitive bool Redact the value from CLI/plan output false
nullable bool Whether null is an acceptable value true
ephemeral bool Value exists only during the run; never persisted to state/plan (1.10+) false

Here is a single variable exercising most of them, which we’ll dissect:

variable "instance_count" {
  description = "Number of app servers to run. Must be between 1 and 9."
  type        = number
  default     = 2
  nullable    = false

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 9
    error_message = "instance_count must be between 1 and 9 (got ${var.instance_count})."
  }
}

type — type constraints

The type argument is a type constraint, not an ordinary value, so it is written without quotes (type = string, not type = "string"). It does two jobs: it rejects values of the wrong shape at plan time with a clear error, and it converts where conversion is unambiguous (the string "5" passed to a number variable becomes 5; true/false convert to/from "true"/"false"). The constraint vocabulary:

Category Constraint Example value Notes
Primitive string "ap-south-1" Unicode text.
Primitive number 2, 3.5 Integers and floats; no separate int type.
Primitive bool true Converts to/from "true"/"false".
Collection list(T) ["a","b"] Ordered, duplicates allowed, accessed by index [0].
Collection set(T) ["a","b"] Unordered, no duplicates, no indexing.
Collection map(T) { env = "dev" } String keys → values of type T.
Structural object({...}) { name = "x", size = 2 } Fixed named attributes, each with its own type.
Structural tuple([...]) ["x", 2, true] Fixed-length, per-position types.
Special any anything No constraint; Terraform infers the type at use.

Collections nest: list(object({ name = string, port = number })) is a list of objects, and map(list(string)) is a map whose values are lists of strings. Prefer a precise constraint over any — it turns a class of mistakes into a plan-time error instead of a confusing failure deep in a resource. The practical difference between list and set matters more than beginners expect: a set deduplicates and has no order, which is exactly what for_each wants, while a list preserves order and position, which is what count consumes.

optional() — optional object attributes with defaults

By default, every attribute of an object type is required — omit one and the plan fails. The optional() modifier (stable since 1.3) marks an attribute optional and, with a second argument, supplies a default that is filled in when the caller leaves it out. This is the single most important feature for building friendly module inputs:

variable "bucket" {
  description = "Bucket configuration; only `name` is required."
  type = object({
    name          = string                      # required
    versioning    = optional(bool, false)        # default false
    force_destroy = optional(bool, false)
    storage_class = optional(string, "STANDARD")
    tags          = optional(map(string), {})    # default empty map
  })
}

A caller may now pass { name = "logs" } and Terraform materialises { name = "logs", versioning = false, force_destroy = false, storage_class = "STANDARD", tags = {} }. Two rules to remember: optional(type) with no default yields null when omitted (so your code must tolerate null), whereas optional(type, default) substitutes the default. Defaults can be applied at nested levels too — an optional object attribute can itself contain optional() attributes, and the defaults cascade. This is how the well-built community modules accept a single object argument yet require almost nothing.

default — optional vs required

If default is present the variable is optional; if it is absent the variable is required, and a run that doesn’t supply it will, in a non-interactive context, fail with “No value for required variable.” (Interactively, terraform plan prompts you for it on the terminal — convenient locally, fatal in CI, which is why required variables in automation must always be supplied explicitly.) A subtlety worth internalising: setting default = null makes the variable optional and gives it the value null when unset — useful for “leave this unmanaged unless the caller opts in”, and the idiomatic way to make an attribute conditionally apply. Note that a default is a fallback, not an override: any value supplied by the caller, the CLI, a file or the environment takes precedence over it (the default sits at the very bottom of the precedence order covered below).

description — documentation that travels

description is a plain string, but it is not cosmetic: it appears in terraform plan when Terraform prompts for a missing variable, it is harvested by documentation generators such as terraform-docs, and it is rendered on the public and private Registry pages for published modules. Treat it as the variable’s API documentation — state the unit, the accepted range, and the consequence of changing it. A description that says “the instance count” is wasted; one that says “Number of app servers (1–9); changing this triggers a rolling replace” earns its keep.

validation — custom rules with custom messages

A variable block may contain one or more validation blocks, each with exactly two arguments:

Validations run at plan time, before any provider is contacted, so they fail fast and cheaply. Multiple blocks are evaluated independently and all failing messages are reported together, which lets you give one precise message per rule rather than one catch-all:

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

variable "cidr_block" {
  type = string
  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "cidr_block must be valid CIDR notation, e.g. 10.0.0.0/16."
  }
  validation {
    condition     = tonumber(split("/", var.cidr_block)[1]) <= 24
    error_message = "cidr_block must be /24 or larger (a smaller prefix number)."
  }
}

The can() function is the workhorse here: it wraps an expression and returns true if it evaluates without error and false otherwise, which turns “is this a parseable CIDR / valid timestamp / decodable JSON?” into a clean boolean. Use validation for input contracts (what the caller may pass). Use precondition/postcondition (covered in the resources lesson) when a check needs to see resource or data-source values that aren’t known until the graph is built — for example, asserting that a chosen AMI is x86_64 by reading a data source. The dividing line: validation guards the input; precondition/postcondition guard the relationship between inputs and the world.

sensitive — redaction (and its limits)

Setting sensitive = true tells Terraform to redact the value wherever it would otherwise print it: in terraform plan, in apply output, and in the human-readable rendering of resources, it shows as (sensitive value). Sensitivity is contagious — any expression derived from a sensitive value is itself treated as sensitive, so a password concatenated into a connection string redacts the whole string. This is genuinely useful, but it is redaction of console output only, not encryption. The value is still written in plaintext to the state file, and the section below on leaks spells out the full list of places it remains visible. For values that must never touch state at all, reach for ephemeral instead.

nullable — whether null is allowed

nullable controls whether the caller may pass null. The default is true (historically and for compatibility). Set nullable = false to forbid null outright — a sensible default for required variables, because it converts a silent “value is null” foot-gun into an explicit plan-time error. The interaction with default is the part people miss:

nullable default Caller passes null Result
true (default) none null is accepted as the value
true "x" null The value is null (null does not fall back to the default)
false "x" null Terraform substitutes the default ("x")
false none null Error — null not allowed and no default to use

The standout row is the third: with nullable = false and a default, passing null quietly uses the default rather than erroring — the only situation in which null is replaced by the default. For most variables, nullable = false with no default (required, non-null) or nullable = false with a default (optional, non-null) is what you want.

ephemeral — values that never persist

Introduced in Terraform 1.10 (and present in OpenTofu), ephemeral = true marks a variable whose value exists only for the duration of a single run and is never written to the state file or the plan file. This is the construct for short-lived secrets — a one-time token, a temporary database password fetched from a vault — that you don’t want lingering in state even encrypted. An ephemeral variable’s value may only flow into other ephemeral contexts (ephemeral resources, write-only resource arguments, ephemeral outputs) and into providers; Terraform errors if you try to route it somewhere persistent, which is the guarantee that makes it safe. It is the strict big brother of sensitive: sensitive hides the value on screen but stores it; ephemeral refuses to store it at all.

Setting variable values: the complete precedence order

You can supply a variable’s value in several ways, and when the same variable is set more than once, Terraform uses a strict, defined order to decide which wins. This is one of the most-tested topics on the Associate exam and the source of countless “but I set it in tfvars!” confusions. The rule, top to bottom, is lowest priority first, last-listed wins:

# Source How it’s provided Loaded automatically? Priority
1 Default in the variable block default = ... n/a Lowest — fallback only
2 Environment variables TF_VAR_<name>=value Yes (from the shell)
3 terraform.tfvars Key/value file in the working dir Yes
4 terraform.tfvars.json JSON variant of the above Yes
5 *.auto.tfvars / *.auto.tfvars.json Any file ending .auto.tfvars, in alphabetical filename order Yes
6 -var-file=FILE On the CLI, in the order the flags appear No (explicit)
7 -var "name=value" On the CLI, in the order the flags appear No (explicit) Highest — last word

Read the table as: a source lower in the list overrides a source higher up for the same variable name. So a -var on the command line beats a -var-file, which beats every .auto.tfvars file, which beats terraform.tfvars, which beats TF_VAR_, which beats the default. Variables set by different sources simply combine; the precedence only decides ties on the same name.

Four details that trip people up, each worth committing to memory:

A few mechanics of the value sources themselves. A .tfvars file uses the same name = value syntax as HCL arguments but contains only assignments, no blocks:

# terraform.tfvars
region         = "ap-south-1"
instance_count = 3
tags = {
  team = "platform"
  env  = "prod"
}

A TF_VAR_ environment variable maps the shell name TF_VAR_<name> to the variable <name>; for complex types the value is parsed as an HCL literal, so a list looks like export TF_VAR_zones='["a","b"]'. And a -var flag sets one value inline: terraform apply -var="instance_count=5". If a required variable is still unset after all seven sources, Terraform prompts interactively (or errors in -input=false/CI mode).

Outputs: every option

An output block exposes a value from a module. From the root module, outputs are printed after apply and retrievable with terraform output. From a child module, outputs are how the parent reads results back (module.<name>.<output>) — a child’s resources are otherwise invisible to the caller. Output blocks accept this complete set of arguments:

Argument Type Purpose
value any expression Required. The value to expose.
description string Documentation; shown in tooling and the Registry.
sensitive bool Redact from CLI output (still stored in state).
depends_on list of references Force the output to wait for given resources (rarely needed).
ephemeral bool Output is available to a parent during the run but never persisted (1.10+).
precondition block A check (inside output) asserting an invariant before the value is finalised.
output "bucket_arn" {
  description = "ARN of the created bucket, for IAM policies in other stacks."
  value       = aws_s3_bucket.this.arn
}

output "db_password" {
  description = "Generated DB password."
  value       = random_password.db.result
  sensitive   = true
}

Notes on the less-obvious arguments. sensitive = true on an output is mandatory if the value is itself sensitive — Terraform refuses to print a sensitive value through a non-sensitive output and errors at plan time, which is a deliberate guard-rail. depends_on is for the rare case where a consumer must not read the output until some side-effecting resource (that the value doesn’t reference directly) has completed; reach for it sparingly, as most ordering is already implied by the references inside value. ephemeral outputs let a child module hand a short-lived secret up to its parent within a single run without that secret ever landing in state — the output-side counterpart to ephemeral variables. A precondition inside an output asserts something must hold before the value is published (e.g. “this list is non-empty”), giving callers a clear failure instead of a confusing downstream one.

After apply, read outputs with terraform output (all), terraform output <name> (one), terraform output -json (machine-readable, and the way to see a sensitive value deliberately), or terraform output -raw <name> (the bare string with no quotes, ideal for piping into another command). Sensitive outputs are redacted in the default listing but are revealed by -json and -raw — a reminder that sensitive is a display convenience, not access control.

Consuming outputs across configurations: terraform_remote_state

Outputs are also the supported way for one Terraform configuration to read another’s results. The terraform_remote_state data source reads the outputs of a separate state file — and only its outputs, never its internal resource attributes:

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "my-tf-state"
    key    = "network/terraform.tfstate"
    region = "ap-south-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
  # ...
}

This is how a layered estate is wired: the network stack publishes private_subnet_id as an output, and the application stack consumes it via remote state. Two cautions. First, only outputs are exposed — if the network stack doesn’t output a value, the consumer simply cannot see it, so outputs become a deliberate, reviewed interface between stacks. Second, reading remote state grants read access to the entire state file, including any sensitive values in it; for cross-team boundaries many teams prefer publishing specific values to a parameter store (SSM, etc.) and reading those instead, so the consumer gets exactly one value and not the whole state. The remote-state-at-scale lesson goes deeper on the backend types and access patterns.

Locals: the DRY tool

A locals block (note the plural keyword, though each entry is referenced as local.<name>, singular) names one or more values for reuse within the module. Unlike a variable, a local cannot be set from outside — it is computed inside the configuration — and unlike an output, it is not exposed to a parent. It exists purely to avoid repetition and to give an intermediate computation a readable name.

locals {
  name_prefix = "${var.project}-${var.environment}"

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

  # Computed once, used everywhere:
  is_production = var.environment == "prod"
}

resource "aws_s3_bucket" "logs" {
  bucket = "${local.name_prefix}-logs"
  tags   = local.common_tags
}

Locals shine in three situations: a value built from other values (a name prefix assembled from project + environment); a constant convention used in many places (a common_tags map merged into every resource); and a complex expression (a for transformation, a merge, a flatten) that you want to name once and keep out of the resource body for readability. You can have multiple locals blocks in a file or module — they all merge into one local.* namespace — and locals may reference other locals, variables, resources and data sources, but a local must not, even transitively, depend on itself (Terraform detects and rejects the cycle).

Local vs variable: which to use

The decision is simply who decides the value:

Use a variable when… Use a local when…
The caller should be able to choose the value The value is derived from other values, not chosen
The value differs per environment (region, instance size) The value is the same logic everywhere (a tag convention)
You want validation of an externally-supplied value You’re naming an intermediate computation for readability
It’s part of the module’s public interface It’s a private implementation detail

The anti-pattern to avoid is a default-only variable that no caller ever overrides and that nothing validates — that’s just a constant wearing a variable’s costume, and it belongs in a local. Conversely, hard-coding a value inline that should vary by environment is the mistake locals can’t fix; that wants a variable.

How variables flow into modules

Variables are how you configure a child module. The child declares variable blocks (its parameters); the parent supplies them as arguments inside the module block — the only place a child’s variables can be set (you cannot pass -var to a nested module from the CLI). The child’s outputs are then read back by the parent:

module "network" {
  source = "./modules/network"

  cidr_block  = "10.0.0.0/16"   # sets the child's var.cidr_block
  environment = var.environment  # forward the root's value down
  tags        = local.common_tags
}

resource "aws_instance" "app" {
  subnet_id = module.network.private_subnet_id  # read the child's output
}

Three rules govern the flow. Required child variables must be set in the module block or the plan fails (the child’s defaults apply otherwise). Values flow strictly downward through arguments and back up through outputs — there are no globals, so a value the child needs must be threaded in explicitly, and a value the parent wants must be exposed as a child output. And sensitivity propagates across the boundary: a sensitive root value passed into a child stays sensitive inside it, and a child output marked sensitive (or derived from a sensitive input) must be treated as sensitive by the parent. This explicit, one-value-at-a-time wiring is verbose but is exactly what makes a Terraform estate auditable.

Sensitive data: where it still leaks

This is the section to read twice, because sensitive promises less than its name suggests. Marking a value sensitive redacts it from console output — and nothing more. Here is the complete map of where a sensitive value remains exposed, and the real mitigation for each:

Location Is a sensitive value exposed there? Real mitigation
terraform plan / apply console No — shown as (sensitive value) This is the one thing sensitive actually does
State file (terraform.tfstate) Yes — full plaintext Encrypt the backend (S3 SSE, etc.); restrict who can read state
Saved plan file (-out=plan.tfplan) Yes — full plaintext Treat plan files as secrets; don’t commit or archive them carelessly
terraform output -json / -raw Yes — value revealed Expected (you asked for it); guard the command’s output
terraform_remote_state consumers Yes — whole state readable Publish specific values to a param store instead; tight state IAM
Debug logs (TF_LOG=trace) Often yes — provider requests may include it Never share trace logs; scrub before attaching to issues
Provider/API side Sent to the API as normal Unavoidable — the resource genuinely needs the value
CLI -var "pw=..." In shell history & process list Use a .tfvars file (gitignored) or TF_VAR_ instead

The practical doctrine that follows: use sensitive = true to keep secrets off the terminal and out of casual screenshots; encrypt and lock down state because that’s where secrets actually live at rest; pass secrets via gitignored .tfvars or TF_VAR_ rather than inline -var; and for the highest bar — secrets that must never persist even in encrypted state — use ephemeral variables/outputs and write-only resource arguments. Best of all, prefer not to put secrets in Terraform at all: generate them (e.g. random_password) or fetch them at apply time from a dedicated secrets manager, so the source of truth is the vault and Terraform only references it.

Diagram showing how variables flow in from defaults, environment, tfvars files and CLI flags through type and validation checks, how locals compute intermediate values, and how outputs flow out to operators, parent modules and remote-state consumers, with annotations of where sensitive data persists.

The diagram traces a value’s full journey: in through the precedence ladder (default → TF_VAR_ → tfvars → auto-tfvars → -var-file-var), through type/validation gates, into resources and local computations, and out through output blocks to the operator, a parent module, or a remote-state consumer — with the persistence points (state, plan) flagged where sensitive does not protect you.

Hands-on lab: variables, validation, locals and outputs — no cloud needed

This lab uses only the random and local providers, so it runs entirely on your machine with zero cost. You’ll declare a typed-and-validated variable, compute a local, generate a sensitive value, expose outputs, and watch the precedence rules in action.

1. Create the working directory and main.tf:

terraform {
  required_version = ">= 1.9.0"
  required_providers {
    random = { source = "hashicorp/random", version = "~> 3.6" }
    local  = { source = "hashicorp/local",  version = "~> 2.5" }
  }
}

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

variable "instance_count" {
  description = "Number of app servers (1–9)."
  type        = number
  default     = 2
  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 9
    error_message = "instance_count must be between 1 and 9 (got ${var.instance_count})."
  }
}

locals {
  name_prefix = "kloudvin-${var.environment}"
  common_tags = {
    environment = var.environment
    managed_by  = "terraform"
  }
}

resource "random_password" "db" {
  length  = 20
  special = true
}

resource "local_file" "manifest" {
  filename = "${path.module}/manifest.txt"
  content  = "prefix=${local.name_prefix}\ncount=${var.instance_count}\n"
}

output "name_prefix" {
  description = "Computed name prefix."
  value       = local.name_prefix
}

output "tags" {
  description = "Common tags applied to resources."
  value       = local.common_tags
}

output "db_password" {
  description = "Generated DB password (sensitive)."
  value       = random_password.db.result
  sensitive   = true
}

2. Initialise and try to apply without setting the required variable:

terraform init
terraform apply -auto-approve

In CI this errors with “No value for required variable” for environment; locally it prompts you. Cancel (Ctrl-C) and instead supply it.

3. Watch validation reject a bad value:

terraform apply -auto-approve -var="environment=production"

Expected output — the plan stops before doing anything:

│ Error: Invalid value for variable
│   environment must be one of: dev, staging, prod.

Note it fails fast, before any resource is touched.

4. Apply correctly, and observe redaction:

terraform apply -auto-approve -var="environment=dev"

The outputs print with db_password = (sensitive value) — redacted — while name_prefix and tags show in full.

5. Reveal the sensitive output deliberately, and inspect state:

terraform output -raw db_password        # prints the bare password
grep -c "result" terraform.tfstate       # the password IS in state, plaintext

This is the key lesson made tangible: sensitive hid it on screen, but terraform output -raw reveals it and the state file contains it in clear text.

6. Prove the precedence rule — -var beats terraform.tfvars:

printf 'instance_count = 3\n' > terraform.tfvars
terraform plan -var="environment=dev"                      # count = 3 (from tfvars)
terraform plan -var="environment=dev" -var="instance_count=7"  # count = 7 (-var wins)

The first plan reads 3 from terraform.tfvars; the second shows 7 because the command-line -var sits at the top of the precedence order.

7. Cleanup:

terraform destroy -auto-approve -var="environment=dev"
rm -f manifest.txt terraform.tfvars terraform.tfstate*

Cost note: zero. The random and local providers create nothing in any cloud — random_password lives only in state and local_file writes a file on disk. Nothing here incurs spend, so you can repeat the lab freely.

Common mistakes & troubleshooting

Symptom Likely cause Fix
No value for required variable in CI Required variable (no default) not supplied; CI is non-interactive Pass it via -var, a -var-file, or a TF_VAR_ env var; or give a sensible default
“But I set it in prod.tfvars!” — value ignored Only terraform.tfvars and *.auto.tfvars auto-load Rename to prod.auto.tfvars or pass -var-file=prod.tfvars
Wrong value wins between two sources Misremembered precedence -var > -var-file > *.auto.tfvars > terraform.tfvars > TF_VAR_ > default
Output refers to sensitive values error Output exposes a sensitive value without sensitive = true Add sensitive = true to the output
Invalid value for variable you didn’t expect A validation condition is false Read the error_message; fix the input or the rule
null slips through where you forbid it nullable left at default true Set nullable = false (note: with a default, null then uses the default)
Error: Reference to undeclared input variable var.x used but no variable "x" block Declare the variable; every var.* needs a variable block
Secret visible in terraform plan despite sensitive Value derived in a way that broke propagation, or printed via -json/-raw Mark intermediate values/outputs sensitive; remember -json/-raw always reveal
Self-referential local error A local depends on itself transitively Break the cycle; locals may not reference themselves

Best practices

Security notes

Interview & exam questions

1. Recite the variable-definition precedence order, lowest to highest. default in the block → environment TF_VAR_*terraform.tfvarsterraform.tfvars.json*.auto.tfvars (alphabetical) → -var-file (CLI order) → -var (CLI order). The last source listed wins for any given variable name.

2. You put values in prod.tfvars but Terraform ignores them. Why? Only terraform.tfvars and files ending in .auto.tfvars are auto-loaded. prod.tfvars must be passed explicitly with -var-file=prod.tfvars (or renamed to prod.auto.tfvars).

3. Does marking a variable sensitive keep its value out of the state file? No. sensitive only redacts the value from CLI/plan output. It is still written in plaintext to state (and to saved plan files, and revealed by terraform output -json/-raw). To keep a value out of state entirely, use ephemeral.

4. What does optional() do in an object type, and what’s the value of an omitted attribute? It makes that attribute optional. optional(type, default) fills the default when omitted; optional(type) with no default yields null, so your code must tolerate null.

5. When would you use a local instead of a variable? When the value is derived from other values or is a constant convention used in many places (e.g. a name_prefix or common_tags), rather than something a caller should choose. Variables are the public interface for caller-supplied values; locals are private, computed values.

6. How does nullable = false interact with a default? With nullable = false and a default, passing null substitutes the default — the only case where null is replaced by the default. With nullable = false and no default, passing null is an error.

7. What’s the difference between a validation block and a precondition? validation (inside variable) checks the input itself at plan time, before providers are contacted, and can reference other variables (since 1.9). precondition/postcondition (inside resource/data/output lifecycle) check relationships involving resource or data-source values that aren’t known until the graph is built.

8. How does one Terraform configuration read another’s outputs? With the terraform_remote_state data source pointed at the other config’s backend; you can read only its outputs (data.terraform_remote_state.x.outputs.y), never its internal resource attributes. Note this grants read access to the whole state file.

9. How are values passed into a child module, and how are results read back? Values are set as arguments inside the module block (the only place — you cannot -var a nested module). Results are exposed by the child as outputs and read by the parent as module.<name>.<output>. Sensitivity propagates across the boundary.

10. You get Output refers to sensitive values. What’s wrong and how do you fix it? An output’s value is sensitive but the output isn’t marked sensitive. Terraform refuses to print a sensitive value through a non-sensitive output. Add sensitive = true to the output.

11. Why prefer for_each (a set/map) over count (a list/number) for variables that drive resource instances? A set/map gives each instance a stable, key-based address, so inserting or removing an element doesn’t re-index and destroy/recreate unrelated instances. A list/count keys by position, so a middle removal shifts every later index. (Covered fully in the resources lesson; the type choice on the variable is what enables it.)

12. What is an ephemeral variable and when would you use one? A variable (Terraform 1.10+/OpenTofu) whose value exists only during a single run and is never written to state or the plan file. Use it for short-lived secrets — a one-time token or a temporary password — that must not persist even in encrypted state; it may only flow into other ephemeral/write-only contexts and providers.

Quick check

  1. Which is the only plain (non-.auto) tfvars filename Terraform loads automatically?
  2. True or false: a value in terraform.tfvars overrides a -var flag on the command line.
  3. What does optional(string) (no second argument) evaluate to when the caller omits the attribute?
  4. Where is a sensitive value stored at rest, despite the redaction?
  5. You want a value computed from two variables and reused across ten resources — variable, output, or local?

Answers

  1. terraform.tfvars (and its .json form). Anything else must end in .auto.tfvars or be passed with -var-file.
  2. False. -var is the highest priority; it overrides terraform.tfvars. (The reverse of the statement is true.)
  3. nulloptional() without a default yields null when omitted, so the consuming code must handle null.
  4. In the state file (and saved plan files), in plaintextsensitive only affects console output.
  5. A local — it’s a derived value reused internally, not a caller choice (variable) or an exported result (output).

Exercise

Build a small module interface that exercises every construct in this lesson. Create a variable "service" of type object({ ... }) with a required name (string), an optional(string, "ap-south-1") region, an optional(number, 1) replica count, and an optional(map(string), {}) tags map. Add two validation blocks: one asserting the replica count is between 1 and 5, another asserting the region is in an allow-list of three regions (use contains). Compute a local.name_prefix from service.name and service.region, and a local.tags that merges service.tags with a fixed { managed_by = "terraform" }. Generate a random_password and expose it as a sensitive output, plus a non-sensitive output for name_prefix. Then: (a) apply passing only { name = "api" } and confirm the defaults fill in; (b) apply with an out-of-range replica count and read the exact error message; © prove that a -var-file=override.tfvars setting the region beats a terraform.tfvars setting; (d) confirm the password is redacted in apply output but visible via terraform output -raw and present in terraform.tfstate. Tear everything down with destroy and remove the local state files.

Certification mapping

This lesson maps directly to the HashiCorp Certified: Terraform Associate (003) objectives. Objective 8 (Read, generate, and modify configuration) is covered end to end: input variables and their options, output values, local values, the type constraint system including optional(), and built-in functions used in validations (contains, can, cidrhost). The variable-definition precedence order is an exam staple and is given here as a complete table. Objective 4 (Terraform workflow) is touched via terraform output and its flags, and Objective 5/6 (state & modules) via terraform_remote_state and passing variables into modules. The sensitive/ephemeral distinction and “where secrets leak” align with the security expectations that recur across both the Associate exam and senior interviews. Everything stated holds for OpenTofu as well, so the knowledge is portable across both tools.

Glossary

Next steps

You now command Terraform’s three value-carrying constructs and the precedence rules that govern them — the prerequisite for everything modular. Next, learn how to package and reuse configuration in Consuming Terraform Modules, In Depth: Sources, Versions, Composition & the Registry (terraform-module-sources-composition-registry-consumption), where the variables you just mastered become a module’s public interface. To anchor these ideas in the broader workflow, revisit Terraform Fundamentals: HCL, Providers, State & the Core Workflow (terraform-fundamentals-hcl-providers-state-workflow). And for the advanced end of typed inputs — deeply nested optional() objects, dynamic blocks, and precondition/postcondition — see Mastering Terraform Dynamic Blocks, Complex Types, and Variable Validation (terraform-dynamic-blocks-complex-types-validation).

TerraformVariablesOutputsLocalsValidationOpenTofu
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments