Variables and Outputs
Three constructs that look like one idea and aren't. A variable is a value from outside, a local is a name for an expression inside, and an output is a value this configuration publishes. Choosing wrongly between the first two is the most common structural mistake in otherwise good Terraform, and the precedence order for supplying variables catches everyone exactly once.
Prerequisites: HCL & the Type System, Resources & References
What & Why
Input variables parameterise a configuration: values supplied at run time rather than written in. Locals name expressions so they can be written once and referenced many times. Outputs expose values from a configuration to whatever called it — a parent module, a person, or another configuration.
The bad practice it replaces
Copying a directory. The pre-parameterisation way to have staging and production was terraform-prod/
and terraform-staging/, identical but for a few strings, and diverging from the moment someone fixed a
bug in one of them. Variables exist so one configuration can serve several environments.
The second bad practice is subtler and current: the magic string repeated eleven times. A bucket
prefix appears in every resource name, in a policy, in a tag, and in an output, and when it changes,
ten of the eleven get updated. locals are the fix, and the reason they matter is that a repeated
literal is a defect waiting for someone to be interrupted.
Where it sits
This page is the mechanics: declaring, typing, validating and supplying values, and publishing results.
It stops before three things. Variables and outputs as the interface of a reusable module — what to
expose and what to keep private — is Modules. The real limits of
sensitive, and ephemeral values, are
Secrets & Sensitive Data. And whether .tfvars
files belong in git was settled in
Anatomy of a Project.
Three things they're confused with
A variable is not a local, and the test is where the value comes from. If a caller could plausibly supply it, it's a variable. If it's derived from other values in this configuration, it's a local. A variable with a default that nobody ever overrides is a local wearing the wrong hat.
An output is not a print statement. It's a published interface. Outputs of a root module are
stored in state and shown after apply; outputs of a child module are how a parent reads its values. Using
them to dump everything for inspection makes the interface meaningless — terraform state show is the
tool for looking at things.
sensitive = true is not encryption. It redacts values from CLI output. State still holds them in
plaintext, and so does a saved plan file.
When NOT to use them
- Don't parameterise what never varies. A variable for something with one possible value adds a place to look without adding capability. Hardcode it, or make it a local.
- Don't use variables to branch on environment.
var.environment == "prod" ? 3 : 1scattered through a configuration means no environment's real shape is readable anywhere. Put the values in per-environment.tfvarsand keep the configuration uniform — see Repo & Environment Structure. - Don't output everything. An output per attribute turns a module's interface into its implementation, and every one becomes something you can't change without breaking a caller.
- Don't use
type = anyto avoid thinking. Covered in HCL & the Type System; the cost lands here, because the error message will name a resource argument rather than the variable you got wrong. - Don't put secrets in variables with defaults, or in committed
.tfvars.sensitive = truehides the value from output and does nothing about the file it came from.
Core Concepts
Input variable — a value from outside. Declared with a variable block; referenced as
var.name. Cannot be referenced by other variables' defaults.
Required variable — no default. Terraform will prompt interactively, or fail with
-input=false, if no value is supplied.
Optional variable — has a default. Used when nothing else supplies a value.
type — the type constraint. Checked and converted on assignment. Omitting it means any.
description — what it's for. Appears in error messages, in terraform-docs output, and in the
registry for published modules. Cheap, and the thing most often skipped.
validation — a rule the value must satisfy. A condition expression and an error_message.
Multiple blocks allowed; each is checked independently. Evaluated before anything is planned.
sensitive — redact from output. Suppresses the value in plan and apply output, and propagates to
anything derived from it. Not encryption; state and plan files are unaffected.
nullable — whether null may be passed. nullable = false rejects an explicit null, which is
distinct from requiring the variable. ⚠️ verify minimum version.
Variable precedence — which source wins. An ordered set of sources, from command line down to default. The specifics are in How It Works and they are the most-misremembered fact on this page.
.tfvars file — values in a file. terraform.tfvars and *.auto.tfvars are loaded
automatically; anything else needs -var-file.
TF_VAR_ environment variable — a value from the environment. TF_VAR_region=eu-west-1 sets
var.region. The usual mechanism in CI for values that aren't secret enough to need a secret store, and
the usual mechanism for ones that are.
Local value — a named expression. Declared inside a locals block, referenced as local.name.
No type declaration, no default, cannot be supplied from outside, and cannot be redefined.
Output — a published value. Declared with an output block. Root module outputs are stored in
state and printed after apply; child module outputs are read by the parent as
module.NAME.OUTPUT.
Output precondition — an assertion before publishing. A check that fails the apply if the value
isn't what it should be. ⚠️ verify version and current placement.
Sensitive output — sensitive = true on an output. Required if the value derives from a sensitive
input, otherwise Terraform errors rather than exposing it.
How It Works
Declaring a variable properly
variable "environment" {
description = "Deployment environment. Determines naming and sizing."
type = string

validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
variable "name_prefix" {
description = "Prefix for all resource names. Lowercase alphanumeric and hyphens."
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,20}$", var.name_prefix))
error_message = "name_prefix must start with a letter, be 2–21 characters, and contain only lowercase letters, digits and hyphens."
}
}
variable "retention_days" {
description = "Days to retain non-current object versions."
type = number
default = 30
validation {
condition = var.retention_days >= 1 && var.retention_days <= 3650
error_message = "retention_days must be between 1 and 3650."
}
}
Three things worth noticing. environment has no default, so it's required — which is right for
something that must be a deliberate choice. Each validation block is independent, so you can have
several with specific messages rather than one condition with a vague one. And the error messages name
the constraint, which is the difference between a validation that helps and one that just blocks.
A validation condition can traditionally only reference the variable it belongs to — not other
variables, not locals, not resources. That restriction was relaxed in a later version to allow
references to other variables and locals. ⚠️ verify which version, and whether cross-variable
references are permitted, before relying on it.
Variable precedence — the fact everyone misremembers
Sources are consulted in order, and later sources override earlier ones:

| Order | Source | Notes |
|---|---|---|
| 1 (lowest) | default in the variable block |
Used if nothing else supplies a value |
| 2 | TF_VAR_name environment variables |
The CI mechanism |
| 3 | terraform.tfvars |
Loaded automatically |
| 4 | terraform.tfvars.json |
Loaded automatically |
| 5 | *.auto.tfvars / *.auto.tfvars.json |
Loaded automatically, in alphabetical order |
| 6 (highest) | -var and -var-file on the command line |
In the order given, last one wins |
⚠️ verify this whole table, and the last row in particular: -var and -var-file are not separate
precedence levels — they're processed in command-line order, so
-var-file=a.tfvars -var region=eu-west-1 and -var region=eu-west-1 -var-file=a.tfvars can give
different answers. That detail is the actual gotcha, and the reason "-var beats -var-file" is
repeated wrongly everywhere.
The practical rules that fall out of it:
TF_VAR_is beaten by aterraform.tfvarsfile in the directory. Which surprises people setting an environment variable in CI and finding it ignored.*.auto.tfvarsloading in alphabetical order means01-base.auto.tfvarsand02-override.auto.tfvarsis a working layering scheme, and relying on it is fragile enough to warn about.- Nothing supplies values to a variable's
default— defaults cannot reference other variables or locals at all, because they're resolved before anything else exists.
Locals
locals {
# Derived once, used everywhere.
name_prefix = "${var.name_prefix}-${var.environment}"
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = var.project
}
# Locals can reference other locals.
bucket_name = "${local.name_prefix}-data"
}
Locals are named expressions, not variables: no type declaration, no default, no external supply, no redefinition. They can reference variables, other locals, resources and data sources, and they're evaluated in dependency order like everything else (HCL & the Type System).
The decision rule between the two is worth stating plainly:
| Question | Answer |
|---|---|
| Could a caller reasonably need to change it? | Variable |
| Is it derived from other values here? | Local |
| Does it differ between environments? | Variable, set per environment |
| Is it the same everywhere but used many times? | Local |
| Does it have a default nobody has ever overridden? | It's a local that was declared as a variable |
That last row is the common failure. A module with forty variables, thirty of which have defaults nobody touches, has thirty things a reader must consider and a caller might break.
Outputs, and what they're actually for
output "bucket_name" {
description = "Name of the data bucket."
value = aws_s3_bucket.data.bucket
}
output "bucket_arn" {
description = "ARN of the data bucket, for policy attachment by consumers."
value = aws_s3_bucket.data.arn
}
output "connection_string" {
description = "Connection string for the storage account."
value = azurerm_storage_account.data.primary_connection_string
sensitive = true # required — the value is sensitive
}
Outputs serve exactly three purposes, and it's worth knowing which one you're serving:
- A module's interface. The only way a parent module reads a child's values. This is the important one, and it's Modules.
- Surfacing values to people and scripts. Printed after apply; retrievable with
terraform output, and-rawfor use in a shell. - Publishing to other configurations, read through
terraform_remote_stateor a data source — with real coupling consequences, discussed in Repo & Environment Structure.
Two mechanical facts. Root module outputs are stored in state, so a sensitive output is a sensitive
value in state — which it already was, but it's worth knowing the output didn't help. And a value
derived from a sensitive input must have sensitive = true or Terraform refuses:
# UNVERIFIED — confirm against a real run
Error: Output refers to sensitive values
To reduce the risk of accidentally exporting sensitive data that was intended
to be only internal, Terraform requires that any root module output containing
sensitive data be explicitly marked as sensitive.
That error is a feature. It's Terraform noticing you were about to print a secret.
sensitive, and what it does not do
variable "api_token" {
description = "Token for the monitoring integration."
type = string
sensitive = true
}

What it does: replaces the value with (sensitive value) in plan and apply output, and propagates —
anything derived from it is also redacted, which is why an unrelated resource argument can suddenly
render as sensitive.
What it does not do:
- It does not encrypt state. The value is plaintext in the state file.
- It does not protect a saved plan file. Also plaintext.
- It does not stop the value reaching the provider or the cloud, which is the point of it.
- It does not hide the value from anyone who can run
terraform output -rawon the state.
So sensitive prevents accidental disclosure in logs and pull request comments, which is genuinely
valuable and is not the same as keeping a secret. The mechanisms that do more — ephemeral values,
external secret stores — are
Secrets & Sensitive Data.
Validation meets provider constraints — where the clouds diverge
The validation block is Terraform's; what you have to validate is the cloud's. A single name_prefix
variable needs a different rule per provider, because object storage naming rules genuinely differ — and
this is the mechanism for catching at plan time what would otherwise fail at apply.
```hcl
variable "name_prefix" {
description = "Prefix for S3 bucket names."
type = string
validation {
# Buckets: 3–63 chars, lowercase letters, digits, hyphens, dots.
# Leaving room for a suffix, so cap the prefix well below 63.
condition = can(regex("^[a-z0-9][a-z0-9-]{1,30}$", var.name_prefix))
error_message = "name_prefix must be 2–31 lowercase alphanumeric characters or hyphens."
}
}
resource "aws_s3_bucket" "data" {
bucket = "${var.name_prefix}-data" # hyphens are fine
}
```
Hyphens are permitted, so the prefix passes through unchanged. The constraint
worth encoding is total length, because the suffix you append counts too.
```hcl
variable "name_prefix" {
description = "Prefix for the storage account name."
type = string
validation {
# Storage accounts: 3–24 chars, lowercase alphanumeric ONLY — no hyphens.
# So the prefix must be short and must already be hyphen-free.
condition = can(regex("^[a-z0-9]{2,18}$", var.name_prefix))
error_message = "name_prefix must be 2–18 lowercase alphanumeric characters, with no hyphens — Azure storage account names disallow them."
}
}
resource "azurerm_storage_account" "data" {
name = "${var.name_prefix}data" # no separator available
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_tier = "Standard"
account_replication_type = "LRS"
}
```
The tightest constraint of the three, and the reason a shared `name_prefix`
across clouds has to satisfy Azure's rules or be transformed. Validating it here
turns an apply-time failure into a plan-time message naming the actual rule.
```hcl
variable "name_prefix" {
description = "Prefix for GCS bucket names."
type = string
validation {
# Buckets: 3–63 chars, lowercase, digits, hyphens, underscores, dots.
condition = can(regex("^[a-z0-9][a-z0-9_-]{1,30}$", var.name_prefix))
error_message = "name_prefix must be 2–31 lowercase alphanumeric characters, hyphens or underscores."
}
}
resource "google_storage_bucket" "data" {
name = "${var.name_prefix}-data"
location = var.location
}
```
Similar to AWS, with underscores additionally permitted. Note the separate trap:
GCP **labels** have their own charset rules, so validating names doesn't cover
metadata — see [HCL & the Type System](./01-hcl-and-types.md).
The generalisable rule: validate against the tightest constraint you must satisfy, which in a multi-cloud repository means Azure's. Canonical comparison in Providers & the Registry.
Getting Started
# main.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = var.region
}
variable "region" {
description = "AWS region."
type = string
default = "eu-west-1"
}
variable "environment" {
description = "Deployment environment."
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
variable "api_token" {
description = "A fake token, to demonstrate redaction."
type = string
default = "not-a-real-secret"
sensitive = true
}
locals {
name_prefix = "tf-vars-demo-${var.environment}"
common_tags = {
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_s3_bucket" "data" {
bucket = "${local.name_prefix}-0725"
tags = local.common_tags
}
output "bucket_name" {
description = "Name of the created bucket."
value = aws_s3_bucket.data.bucket
}
output "token_length" {
description = "Derived from a sensitive value, so it must be marked sensitive too."
value = length(var.api_token)
sensitive = true
}
1 · A required variable, unsupplied
terraform init
terraform plan
# UNVERIFIED — confirm against a real run
var.environment
Deployment environment.
Enter a value:
The description is doing work — it's what a prompt shows. In CI you'd use -input=false, which turns
this into a clean failure instead of a hang.
2 · Trip the validation
terraform plan -var environment=production
# UNVERIFIED — confirm against a real run
Error: Invalid value for variable
on main.tf line 16:
16: variable "environment" {
environment must be one of: dev, staging, prod.
This was checked by the validation rule at main.tf:19,3-13.
Note production is wrong where prod is right — exactly the sort of mistake that would otherwise
create a whole environment named inconsistently and be discovered a month later.
3 · Watch precedence
echo 'environment = "staging"' > terraform.tfvars
terraform plan # → staging, from the file
TF_VAR_environment=dev terraform plan # → still staging: the file beats TF_VAR_
terraform plan -var environment=dev # → dev: the command line wins
The middle line is the lesson, and it's the one that costs people an afternoon in CI: setting a
TF_VAR_ environment variable does not override a terraform.tfvars file in the directory.
4 · See redaction, and its limits
terraform apply
# UNVERIFIED — confirm against a real run
Outputs:
bucket_name = "tf-vars-demo-staging-0725"
token_length = <sensitive>
Now find the value anyway:
terraform output -raw token_length
grep -o 'not-a-real-secret' terraform.tfstate
# UNVERIFIED — confirm against a real run
17
not-a-real-secret
The "secret" is sitting in the state file in plaintext. That's the whole point of the section:
sensitive is a disclosure control for logs and terminals, not a secret-management mechanism.
5 · Try to leak one accidentally
Remove sensitive = true from the token_length output:
# UNVERIFIED — confirm against a real run
Error: Output refers to sensitive values
Terraform refused. Put it back, then:
terraform destroy
rm terraform.tfvars
In Practice
The production shape of a variable — description, type, validation, and a default only when there genuinely is a sensible one:
variable "buckets" {
description = <<-EOT
Buckets to create, keyed by logical name. The key becomes part of the
resource address, so treat it as permanent — changing it destroys and
recreates the bucket.
EOT
type = map(object({
versioning_enabled = bool
retention_days = optional(number, 30)
tags = optional(map(string), {})
}))
validation {
condition = alltrue([for k in keys(var.buckets) : can(regex("^[a-z][a-z0-9-]{1,20}$", k))])
error_message = "Bucket keys must be lowercase alphanumeric with hyphens — they become resource addresses."
}
validation {
condition = alltrue([for b in values(var.buckets) : b.retention_days >= 1])
error_message = "retention_days must be at least 1."
}
}
Two separate validation blocks, each with a message naming its own rule. And a description that
explains the consequence of a choice rather than restating the type — which is the difference between
documentation and decoration.
Layer .tfvars by environment, don't branch on environment in code. The maintainable pattern:
envs/
├── dev.tfvars environment = "dev", retention_days = 7
├── staging.tfvars environment = "staging", retention_days = 30
└── prod.tfvars environment = "prod", retention_days = 365
terraform plan -var-file=envs/prod.tfvars -out=tfplan
The alternative — var.environment == "prod" ? 365 : 7 scattered through the configuration — means no
single place tells you what production looks like, and every new setting adds another conditional. The
.tfvars file is the description of the environment, and it reviews well because a diff to it is a
diff to one environment. The stronger version of this argument, and where directories beat both, is
Repo & Environment Structure.
Get secrets from a secret store, not from variables. The workable options, in rough order of
preference: a data source reading from the cloud's secret manager, so the value never exists in your
repository; TF_VAR_ populated from the pipeline's secret store at run time; and — only for values that
aren't really secret — a committed .tfvars. What doesn't work is a sensitive variable with a default,
because the default is in git.
Keep outputs few and stable. For a module, each output is a promise. Publish what consumers
genuinely need — identifiers, ARNs, endpoints, names — and resist publishing the whole resource, because
module.x.bucket is a contract you can keep and module.x.everything is one you can't.
Use terraform output -raw in scripts, and -json for anything structured:
BUCKET=$(terraform output -raw bucket_name) # unquoted, for shells
terraform output -json | jq -r '.bucket_name.value'
What a reviewer should look for in a diff touching this topic:
- A new variable with no
description. Cheapest possible comment, and the description shows up in prompts and generated docs. - A variable with no
type, ortype = any. - A variable that should be a local — a default nobody will override, or a value derived from other variables.
- A local that should be a variable — something that visibly differs between environments but is
hardcoded in
locals. - A missing
validationon anything with a real constraint, especially names, and especially where the constraint is a cloud's rather than yours. - A
maporobjectkey that becomes a resource address, without validation on its charset. - A secret-shaped variable with a default. The default is in git regardless of
sensitive. - A new output. Is it needed, is it named for its role rather than its source, and does it need
sensitive? - A removed or renamed output. That's a breaking change for every caller.
- Conditionals on
var.environmentin resource arguments. Suggest a.tfvarsvalue instead.
Blast radius and rollback. Mostly low: bad values fail at plan time, which is the cheapest failure
available, and that's precisely what validation buys you. Two exceptions. A change to a variable that
feeds a name or a map key changes resource addresses or immutable arguments, and shows up as
replacement — so read the plan. And removing or renaming a module output breaks callers at plan time
rather than at apply, which is inconvenient rather than dangerous.
Ecosystem
terraform-docs. Generates a table of inputs, outputs, descriptions, types and defaults from the
configuration itself. It makes the description field pay for itself, and a CI check that fails when the
generated section is stale keeps a module's documentation honest. Near-mandatory once you publish
modules (Modules).
terraform output -raw / -json. The scripting interface. -raw for single unquoted values,
-json for structured consumption. This is how a Terraform apply hands values to the next step of a
pipeline without anyone parsing terminal output.
terraform console -var-file=.... Evaluate locals and expressions with real inputs loaded, which is
the fastest way to check that a for expression over a variable produces what you intended before
committing it.
Cloud secret managers, via data sources. AWS Secrets Manager and Parameter Store, Azure Key Vault, GCP Secret Manager — each has a data source, so a secret can be read at plan time and never stored in your repository. It still lands in state, which is the limitation that Secrets & Sensitive Data addresses.
tflint. Flags unused variables and some declaration problems that validate accepts. Modest value
here compared with its provider-aware rules, and it's free once configured.
Testing & Validation.
Provider-specific note. Variables, locals and outputs are pure Terraform and behave identically everywhere. What differs is what you must validate — Azure's storage account naming is the tightest of the three and effectively sets the constraint for any shared prefix variable — and which secret manager data source you reach for.
Production
Security
This page contains the most common way secrets enter a repository: a variable with a default, or a
committed .tfvars. Neither is mitigated by sensitive, which only redacts terminal output. The
controls that work: read secrets from a secret manager through a data source, or inject them via
TF_VAR_ from the pipeline's secret store at run time; gitignore .tfvars by default with explicit
exceptions; and mark genuinely sensitive inputs and outputs sensitive so they don't reach pull request
comments and CI logs, which is a real and frequent leak path even though it isn't encryption.
Blast radius
Small by design — invalid values fail before anything is created, and validation is how you move
failures earlier. The two things that aren't small: a variable feeding a resource name or a for_each
key changes addresses and therefore proposes replacement, and a renamed module output breaks every
caller. Both are visible in a plan, which is the argument for reading plans rather than trusting that
variable changes are inert.
Scale
Negligible runtime cost. Two human costs that matter more: a module with forty variables is
unusable — most of them are defaults nobody understands well enough to change — and validation
expressions over very large collections (alltrue over thousands of keys) are evaluated before planning
begins, so they add plan-time latency. The first is the real problem, and the fix is fewer variables with
better types.
Team workflow
Two conventions worth writing down: every variable has a description and an explicit type, and
anything with a cloud-imposed constraint has a validation. Both are invisible to tooling apart from
terraform-docs and tflint, so they live in review. The third, more contested one: environment
differences live in .tfvars files rather than conditionals in the configuration — worth agreeing
explicitly, because the alternative accretes one conditional at a time until nobody can describe
production.
Reliability
validation is the reliability feature on this page, and it's underused. Every constraint you encode
moves a failure from apply time — partway through creating infrastructure — to plan time, before anything
happens. Naming rules, allowed regions, size bounds, key charsets: each is a class of incident that
becomes an error message. The limit is that validation can only see the variable, not the cloud, so it
cannot check quotas, uniqueness or permissions; those still fail at apply.
Interview Questions
Conceptual
What's the difference between a variable and a local?
A variable is a value from outside the configuration — declared with a variable block, supplied by a
caller, a file, the environment or a default. A local is a named expression inside the configuration,
declared in a locals block, derived from variables, other locals, resources or data sources, and not
supplyable from outside.
The test: could a caller reasonably need to change it? Then it's a variable. Is it derived from things already here? Then it's a local. The common failure is a variable with a default nobody has ever overridden — that's a local that was declared wrongly, and it adds a parameter a reader must consider and a caller might break.
What is variable precedence, and what's the order?
Several sources can supply a value, and later ones override earlier ones. Lowest to highest: the
default in the variable block; TF_VAR_ environment variables; terraform.tfvars;
terraform.tfvars.json; *.auto.tfvars files in alphabetical order; and finally -var and -var-file
on the command line.
The nuance worth knowing is that -var and -var-file aren't separate levels — they're applied in the
order they appear on the command line, so the last one wins. And the practical gotcha is that
TF_VAR_ is below terraform.tfvars, so an environment variable set in CI is silently ignored if a
terraform.tfvars file exists in the directory.
What are outputs actually for?
Three things. Primarily, they're a module's interface — the only way a parent module can read a child's
values, as module.NAME.OUTPUT. Secondly, surfacing values to people and scripts, retrievable with
terraform output, and -raw for shell use. Thirdly, publishing values for other configurations to
consume through remote state or data sources.
What they aren't is a debugging print statement. Root module outputs are stored in state, each one is a
promise to consumers, and outputting every attribute turns a module's interface into its implementation —
terraform state show is the tool for looking at things.
What does `sensitive = true` do, and what does it not do?
It redacts the value from plan and apply output, showing (sensitive value), and it propagates —
anything derived from it is redacted too, which is why an unrelated argument can suddenly render as
sensitive. It also forces you to mark any output derived from it as sensitive, or Terraform errors.
It does not encrypt state, where the value sits in plaintext; it does not protect a saved plan file,
likewise plaintext; and it doesn't stop terraform output -raw retrieving it. So it's a control against
accidental disclosure in logs, terminals and pull request comments — which is a real and frequent leak
path — and it is not secret management.
Why not branch on `var.environment` inside the configuration?
Because the environment's actual shape stops being readable anywhere. Once sizing, retention, replica
counts and feature flags are each a ternary on var.environment, answering "what does production look
like?" means reading the whole configuration and evaluating conditionals in your head — and every new
setting adds another one.
The alternative is per-environment .tfvars files, where the file is the description of the
environment and a diff to it is scoped to one environment. It also fails better: a missing value is a
plan-time error rather than silently taking the else branch. The stronger version — separate directories
with separate state — is Repo & Environment Structure.
Technical depth
What can a `validation` condition reference?
Traditionally only the variable it belongs to — not other variables, not locals, not resources — because it's evaluated very early, before anything else is resolved. A later Terraform version relaxed this to permit references to other variables and locals, which makes cross-field validation possible.
Multiple validation blocks per variable are allowed and each is checked independently, which is better
than one compound condition because each can carry a message naming its own rule. And can() around a
regex() is the standard idiom, since regex errors rather than returning false on no match.
Can a variable's `default` reference another variable?
No. Defaults are resolved before variable values exist, so they must be literal — no var., no local.,
no functions over other variables. This catches people trying to derive one default from another.
The workaround is to make the derived value a local instead: leave the variable without a default or
with a simple one, and compute the composite in locals where references are fine. That's usually the
right structure anyway, because a value derived from other values was never really an input.
Where do root module outputs live, and why does that matter?
In state. Which means a sensitive output is a sensitive value in the state file — though it already was, since state records every attribute anyway, so the output doesn't make things worse.
Two practical consequences. terraform output reads state rather than re-running anything, so it's fast
and works without credentials for the cloud (though it needs access to the backend). And another
configuration can read those outputs via terraform_remote_state, which is why outputs are a coupling
surface and not just a display mechanism — see
Repo & Environment Structure.
How would you supply a secret to Terraform?
Best: don't supply it. Read it inside the configuration from the cloud's secret manager via a data
source, so the value never exists in your repository or your CI configuration. Next best: TF_VAR_
populated at run time from the pipeline's secret store, marked sensitive in the variable declaration.
What doesn't work: a sensitive variable with a default, because the default is committed; a .tfvars
file with the secret in it, unless it's genuinely gitignored and injected; and -var on the command
line, which lands in shell history and CI logs.
The caveat to state either way: however it arrives, the value ends up in state in plaintext, so the backend's access controls are part of your secret management whether you intended that or not.
What's the difference between a required variable and `nullable = false`?
A required variable — one with no default — must be given a value; Terraform prompts interactively, or
fails under -input=false. nullable = false says that if a value is supplied, it may not be an
explicit null.
They're orthogonal: an optional variable with a default can still be passed null explicitly, which
overrides the default with nothing rather than falling back to it — a genuinely surprising behaviour and
the reason nullable exists. In modules, nullable = false is the way to say "if you pass this, pass
something real."
How does this differ across AWS, Azure and GCP?
The constructs are pure Terraform and behave identically. What differs is what you have to validate,
because the cloud's constraints are what a validation block exists to catch before apply.
Object storage naming is the sharpest case. AWS S3 buckets allow 3–63 lowercase characters including
hyphens and dots. GCP buckets are similar and additionally allow underscores. Azure storage accounts are
3–24 characters, lowercase alphanumeric only — no hyphens at all. So a name_prefix variable shared
across clouds must satisfy Azure's rules or be transformed, and validating against the tightest
constraint is the practical approach.
Two secondary differences: GCP labels have charset rules that name validation doesn't cover, so metadata needs its own handling; and the secret manager data source differs per cloud — Secrets Manager or Parameter Store, Key Vault, Secret Manager — which is the one place a secrets-handling pattern isn't portable. Canonical comparison in Providers & the Registry.
Scenario and design
A module has 40 variables, 30 with defaults. Critique it.
Most of those defaults are probably locals in disguise — values derived from other inputs, or constants that no caller has ever changed. Each one is a parameter a reader must consider, a promise the module must keep, and a way for a caller to produce a configuration the author never tested.
I'd look for three things. Values computed from other variables: move them to locals. Groups of related
scalars — bucket_versioning, bucket_retention, bucket_tags — collapse into one object variable
with optional() defaults, which is one input instead of three and self-documenting. And genuine
constants with no defensible alternative: hardcode them and delete the variable.
The remaining question is whether the module is doing too much. Forty inputs often means one module serving three unrelated use cases, and splitting it is a better fix than tidying the interface.
Design variable handling for one configuration serving dev, staging and production.
One configuration, uniform in shape, with a variable for everything that differs and a per-environment
.tfvars file supplying it — envs/dev.tfvars, envs/staging.tfvars, envs/prod.tfvars — applied with
-var-file. environment itself is required with no default and validated against the allowed set, so
you cannot apply without stating which environment you mean.
Everything derived goes in locals: name prefixes, tag maps, computed names. Secrets come from a secret
manager data source or TF_VAR_ at run time, never from the committed files. Validation on every
constrained input, especially names and any key that becomes a resource address.
What I'd avoid: conditionals on var.environment in resource arguments, and terraform.tfvars as a
filename here — because it loads automatically, which means forgetting -var-file silently applies the
wrong environment rather than failing.
The honest caveat is that this shares one state across environments unless the directory layout separates them, which is a bigger decision: Repo & Environment Structure.
Your `TF_VAR_environment=prod` in CI is being ignored. Why?
Almost certainly a terraform.tfvars file in the working directory, which is loaded automatically and
sits above TF_VAR_ in the precedence order. An *.auto.tfvars file does the same. This is the
single most common precedence surprise, because people reasonably assume an explicit environment
variable beats a file.
Other candidates: the variable name doesn't match — TF_VAR_ is case-sensitive and must match the
declared name exactly; the variable is being shadowed by a -var or -var-file argument later in the
command; or the environment variable isn't reaching the process at all, which happens with certain CI
runner and container configurations.
Diagnosis: terraform console and evaluate var.environment, or check whether a .tfvars file exists
in the directory. The fix I'd prefer is explicit -var-file=envs/prod.tfvars rather than relying on
either mechanism, so the environment is visible in the command.
A colleague adds an output exposing a database password so another team can consume it. What do you say?
Two separate problems. It needs sensitive = true or Terraform will refuse anyway, so that part is
self-correcting — but marking it sensitive doesn't make it safe, it just stops it printing. The output
lands in state, and anyone who can read the state file or run terraform output -raw gets the password;
if a consuming configuration reads it via terraform_remote_state, the secret is now in a second state
file too, and that spread is the real problem.
The better design is that the consumer reads the secret from the secret manager directly, with IAM deciding who may. Terraform's job is to create the secret and grant access to it, not to transport its value between configurations. If the password is generated by Terraform, write it to the secret manager as a resource and output only its identifier.
Worth adding: if this has already been applied anywhere, the password should be rotated, because it's in state history and possibly in a CI artefact.
Commands & Gotchas
terraform plan -var environment=dev # highest precedence
terraform plan -var-file=envs/prod.tfvars # ...applied in command-line order
TF_VAR_environment=dev terraform plan # beaten by terraform.tfvars
terraform plan -input=false # fail instead of prompting — always in CI
terraform output # all outputs, from state
terraform output -raw bucket_name # one value, unquoted, for shells
terraform output -json | jq -r '.bucket_name.value' # structured
terraform console -var-file=envs/dev.tfvars # evaluate locals with real inputs
terraform-docs markdown . > README.md # inputs/outputs table from descriptions
| Behaviour | Why it matters |
|---|---|
TF_VAR_ is below terraform.tfvars in precedence |
The most common precedence surprise, and it bites in CI |
-var and -var-file apply in command-line order |
Not separate levels. The last one wins |
*.auto.tfvars load automatically, alphabetically |
A layering scheme that works and shouldn't be relied on |
terraform.tfvars loads without being named |
So forgetting -var-file can silently apply the wrong environment |
A variable default cannot reference anything |
Resolved before values exist. Derived values belong in locals |
validation can only see its own variable (traditionally) |
Cross-field checks need a later version — ⚠️ verify |
Multiple validation blocks per variable are allowed |
Prefer several specific messages over one vague condition |
sensitive redacts output only |
State and plan files hold the value in plaintext |
sensitive propagates to derived values |
Which is why an unrelated argument can render as redacted |
| A sensitive-derived output must be marked sensitive | Terraform errors rather than printing it. That error is a feature |
| Root module outputs are stored in state | Which is what makes them readable by other configurations |
| Renaming or removing an output breaks callers | It's an interface change, at plan time |
null passed explicitly overrides a default with nothing |
It does not fall back. nullable = false prevents it |
A variable that feeds a name or a for_each key changes addresses |
So a "harmless" variable edit can propose replacement |
← Back to The Language · Next: Expressions & Functions →
⚠️ Verification checklist (delete before publishing)
The precedence table — the highest-value verification on this page, because it's the thing the page promises to get right and the thing most sources get wrong
- Full ordering, lowest to highest: default →
TF_VAR_→terraform.tfvars→terraform.tfvars.json→*.auto.tfvars→ command line. Verify every step against current documentation and by experiment. - That
-varand-var-fileare applied in command-line order rather than as two distinct levels. The page makes this the headline nuance and repeats it in an interview answer and the gotchas table. If it's wrong, three places change. - That
*.auto.tfvarsfiles load in alphabetical order. - That
TF_VAR_is genuinely belowterraform.tfvars. This drives a whole scenario answer and the step-3 demonstration. Test it directly. - Whether
terraform.tfvars.jsonis really a separate precedence step fromterraform.tfvars.
Command and error output
- The interactive prompt format for a missing required variable, including whether the description appears as shown.
- The
Invalid value for variableerror format, including the trailing "This was checked by the validation rule at …" line and its line/column notation. - The
Output refers to sensitive valueserror text. - Redaction tokens. The page uses
<sensitive>in theOutputs:block and(sensitive value)in prose about plan output. These may genuinely be context-dependent — output blocks and plan bodies may render differently — so verify both independently rather than assuming one is a typo. If they are the same token, make the page consistent. - The step-4 demonstration: that
terraform output -rawreturns the value and that grepping state finds the plaintext. Confirm before publishing, since it's the page's central security point.
Feature and version claims
- Whether
validationconditions can reference other variables and locals, and from which version. Flagged inline as uncertain; it appears in Core Concepts, How It Works, an interview answer and the gotchas table. Resolve once and reconcile all four. -
nullableminimum version, and that an explicitnulloverrides a default rather than falling back to it. The second claim carries an interview answer. - Output
precondition— version, and whether it belongs in apreconditionblock on the output. Flagged inline. - That
can(regex(...))is the correct idiom becauseregexerrors rather than returning false. -
optional(type, default)version — same item as01-hcl-and-types.md. - That
terraform outputworks without cloud credentials given backend access.
Provider-specific claims in the tab set
- AWS S3 bucket naming: 3–63 chars, lowercase, hyphens and dots.
- GCP bucket naming: whether underscores are genuinely permitted.
- Azure storage account: 3–24 lowercase alphanumeric, no hyphens. Now asserted on five pages — verify once and fix everywhere.
- That the regexes as written actually match what the prose claims about lengths. Check the
arithmetic on each:
{1,30}after a leading character means 2–31 total, and the page says so in three places.
Versions
-
~> 5.0aws — tenth page. This should now be a single scripted sweep acrossdocs/.
Structure
- One
<Tabs>block, for per-cloud validation rules. Confirm a page about pure-Terraform constructs justifies a tab set at all — I think it does, because what you validate is the divergence, but it's the most arguable tab set in the article. - Length: check rendered length.
- All relative links resolve.