Terraform Variables and Outputs
How data flows through Terraform. Input variables, local values and outputs, where each belongs, precedence rules, and how to handle secrets safely.
Three constructs move data through a Terraform configuration, and choosing the right one is most of what makes a configuration readable.
| Construct | Direction | Set by |
|---|---|---|
| Input variable | In | The caller: tfvars, CLI, environment, parent module |
| Local value | Internal | Computed inside the configuration |
| Output value | Out | Returned to the caller or read after apply |
The rule of thumb: if someone deploying needs to choose it, make it a variable. If it is derived logic you want to name once, make it a local. If something outside needs to read it, make it an output.
Input variables
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "instance_count" {
description = "Number of application instances"
type = number
default = 2
}
A variable with no default is required. Terraform prompts for it interactively, which in CI means a hung pipeline unless -input=false is set.
Always write a description. It is what terraform-docs renders and what a colleague reads instead of your source.
Types
string, number, bool, plus collections, list(string), map(string), set(string), and structural types:
variable "subnets" {
type = map(object({
cidr = string
az = string
public = optional(bool, false)
}))
}
Typing an object rather than accepting any turns a runtime failure deep inside a resource into a clear type error at plan time. optional() with a default keeps the interface small for simple callers.
Validation
validation blocks are underused and cheap. Rejecting a bad value at plan time with your own message beats a provider error three resources later that names nothing useful.
Passing values in
Five mechanisms, evaluated lowest to highest precedence:
| Source | Precedence |
|---|---|
default in the declaration | Lowest |
TF_VAR_name environment variable | ↓ |
terraform.tfvars | ↓ |
*.auto.tfvars, alphabetically | ↓ |
-var-file on the command line | ↓ |
-var on the command line | Highest |
Precedence is per variable, so a tfvars file setting one value leaves everything else at its default.
terraform apply -var-file=production.tfvars
terraform apply -var="instance_count=5"
export TF_VAR_environment=staging
For real projects, keep a tfvars file per environment in version control, as long as it holds no secrets. That makes the configuration reproducible and the values reviewable.
Secrets
Mark the variable sensitive and supply the value from outside:
variable "db_password" {
type = string
sensitive = true
}
sensitive = true redacts the value from plan and apply output. That is all it does. The value is stored in plain text in state, so state encryption and access control remain the actual protection.
Better still, do not pass secrets in at all. Read them at plan time from a secret manager:
data "aws_secretsmanager_secret_version" "db" {
secret_id = "prod/payments/db"
}
The value then never exists in a tfvars file, a CI variable, or anyone's shell history.
Local values
Locals name internal logic. They cannot be set from outside, and they are computed once per run:
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket" "artifacts" {
bucket = "${local.name_prefix}-artifacts"
tags = merge(local.common_tags, { Name = "artifacts" })
}
The common-tags pattern is the most valuable use: one place to change your tagging convention, applied everywhere.
Locals can be overused. A configuration where every literal is indirected through a local is harder to read, not easier. Reserve them for values used several times, expressions complex enough that a name helps, and anything you expect to change.
Outputs
output "bucket_name" {
description = "Name of the artifacts bucket"
value = aws_s3_bucket.artifacts.id
}
output "db_endpoint" {
value = aws_db_instance.main.endpoint
sensitive = true
}
Outputs do two jobs. They surface values to humans after an apply, and, more importantly, they are the only way a module returns anything to its caller. A parent module cannot reach inside a child to read a resource attribute, so anything a caller might need must be an output.
terraform output # everything
terraform output bucket_name # one value
terraform output -raw bucket_name # unquoted, for shell interpolation
terraform output -json # machine-readable, for scripts
Note that sensitive outputs appear unredacted in -json, so avoid printing the whole document into a CI log.
Explore further in this section
| Sub-topic | What you will learn |
|---|---|
| Input Variables | Types, validation, tfvars and precedence in depth |
| Local Values | Expressions, functions and common patterns |
| Output Values | Module interfaces, sensitive outputs, CI usage |
Quick reference
| Task | Syntax |
|---|---|
| Declare an input | variable "name" { type = string } |
| Reference it | var.name |
| Declare a local | locals { key = expression } |
| Reference it | local.key |
| Declare an output | output "name" { value = ... } |
| Read an output | terraform output name |
| For a shell script | terraform output -raw name |
| Pass a file | terraform apply -var-file=prod.tfvars |
| Pass one value | terraform apply -var="key=value" |
| Environment variable | export TF_VAR_key=value |
Final Thoughts
Variables in, locals for internal logic, outputs out. Most unreadable Terraform comes from blurring those lines. Parameterising things that never vary, or computing in-line what should be a named local. Keep the required variable surface small, give everything a description, and remember that sensitive protects logs rather than state.
Frequently Asked Questions
What is the difference between a variable, a local and an output in Terraform?
An input variable is supplied from outside the configuration, by a tfvars file, the command line, an environment variable, or a parent module, and is how the same code serves different environments. A local value is computed inside the configuration and cannot be overridden externally; it exists to name derived logic and avoid repetition. An output exposes a value outward, either for a human to read after apply or for a parent module to consume. Variables flow in, locals stay internal, outputs flow out.
What is the precedence order for Terraform variable values?
Lowest to highest: the default in the declaration, then TF_VAR_ environment variables, then terraform.tfvars, then *.auto.tfvars files alphabetically, then -var-file on the command line, and finally -var, which beats everything. Precedence applies per variable, so a tfvars file that sets one value leaves the others at their defaults. When a value is not what you expect, printing it through terraform console is the quickest way to find which layer supplied it.
How do I keep secrets out of my Terraform configuration?
Do not put them in tfvars files that are committed. Mark the variable sensitive = true so it is redacted from output, then supply the value through an environment variable or a CI secret, or better, read it at plan time from AWS Secrets Manager, SSM Parameter Store or Vault with a data source, so it never exists in a file at all. Remember sensitive only hides values from console output; they are stored in plain text in state, which is why state encryption and access control matter regardless.
Do I need a tfvars file for every environment?
It is the common and workable pattern: dev.tfvars, staging.tfvars, production.tfvars, selected with -var-file. Keep them in version control so environment differences are reviewable, and keep secrets out of them. The alternative, separate directories per environment, each with its own backend, gives stronger isolation because credentials and state can be scoped separately, and is worth the extra structure once production is involved.
When should I use a local instead of a variable?
When the value is internal logic rather than something a caller chooses. A name prefix built from other variables, a map of common tags, a computed CIDR. All locals. If a person deploying would reasonably want to set it differently, it is a variable. A frequent mistake is making everything a variable, which produces modules with twenty inputs where three would do and makes them unpleasant to use.
Why can't I read a value from inside a module without an output?
Because the module interface is deliberately a contract. A parent module can only see what a child explicitly exposes through output blocks, which is what lets a module change its internals without breaking callers. If you need a value from a module, add an output for it, and when writing a module, expose anything a caller might plausibly want, since adding an output later is easy but callers have no way to work around a missing one.