Terraform Variables, Outputs, and Locals
Terraform inputs and outputs done right: variable blocks with types, defaults, and validation, the precedence of tfvars and TF_VAR_ env vars, sensitive values, output values, and locals for DRY.
A Terraform configuration that hardcodes every value is a configuration you can only use once. Variables let the same code stand up dev, staging, and prod with different inputs. Outputs let one configuration hand computed values (an IP, a database endpoint, a generated password) to the next. Locals let you name a repeated expression once instead of copying it everywhere. These three constructs are what turn a one-off script into reusable infrastructure, and they show up in every real module you will ever write. This guide covers all three, plus the part people trip over most: how Terraform decides which value actually wins when a variable is set in five different places. It assumes you know the basics from Terraform fundamentals.
Input variables: the parameters of your configuration
An input variable is a value you pass into a configuration from the outside. You declare it with a variable block, and then reference it anywhere as var.<name>. Think of the block as the function signature: it names the input, says what type it accepts, and optionally gives a default and a description.
variable "instance_type" {
description = "EC2 instance size for the web tier"
type = string
default = "t3.micro"
}
variable "instance_count" {
description = "Number of web instances to run"
type = number
default = 2
}
variable "enable_monitoring" {
description = "Turn on detailed CloudWatch monitoring"
type = bool
default = false
}
You then use them where the value is needed:
resource "aws_instance" "web" {
count = var.instance_count
instance_type = var.instance_type
monitoring = var.enable_monitoring
ami = "ami-0abcdef1234567890"
}
Three fields matter on every variable block. description is not optional in practice - it shows up in terraform plan prompts and in generated docs, and a module without descriptions is a module nobody can use without reading its source. type constrains what can be passed and catches mistakes at plan time instead of at apply time (more on types below). default makes the variable optional: if a variable has no default and the caller does not supply a value, Terraform stops and prompts for it (or errors, in automation). Leave off the default when the value genuinely has no safe fallback - a VPC ID, an environment name - so a missing value fails loudly instead of quietly using something wrong.
Validation blocks: fail early, fail clearly
A type catches "you passed a string where I wanted a number." A validation block catches "you passed a string, but it is not one I accept." This is how you enforce real rules - allowed regions, naming conventions, sane ranges - at plan time, with an error message you control.
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 "instance_count" {
description = "Number of instances (1-10)"
type = number
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "instance_count must be between 1 and 10."
}
}
The condition must evaluate to true for the value to be accepted; when it is false, Terraform prints your error_message and refuses to plan. A validation block that catches a typo before it becomes a half-built environment pays for itself the first time it fires.
Setting variables, and who wins
Here is the part that confuses people. A variable can be given a value in several ways at once, and Terraform has a strict order of precedence. When the same variable is set in multiple places, the last one to be applied wins. From lowest priority to highest:
- The
defaultin thevariableblock - the fallback when nothing else sets the value. terraform.tfvars(andterraform.tfvars.json) - loaded automatically if present. This is your default values file for a given root configuration.*.auto.tfvars(and*.auto.tfvars.json) - any file with this suffix is loaded automatically, in alphabetical order by filename.TF_VAR_<name>environment variables -export TF_VAR_region=us-east-1setsvar.region. Common in CI where secrets and per-run values come from the environment.-varand-var-fileon the command line - the highest priority, applied in the order you write them (later flags override earlier ones).
A .tfvars file is just variable assignments, no variable blocks:
# terraform.tfvars
environment = "prod"
instance_type = "t3.large"
instance_count = 4
And the command-line forms:
# a single value on the CLI (highest priority)
terraform apply -var="instance_count=6"
# an explicit vars file (not auto-loaded, so you name it)
terraform apply -var-file="prod.tfvars"
# an environment variable (great for CI)
export TF_VAR_environment="staging"
terraform apply
The practical pattern: keep shared defaults in terraform.tfvars, keep per-environment values in named files like prod.tfvars and staging.tfvars that you pass with -var-file, and let CI inject secrets through TF_VAR_ env vars so they never land in a committed file. When a value seems "stuck" on the wrong setting, walk this list from the bottom up - a stray -var flag or an *.auto.tfvars file is almost always the culprit.
Sensitive variables: keeping secrets out of the output
Some inputs are secrets - a database password, an API token. Mark the variable sensitive = true and Terraform redacts it in plan and apply output, showing (sensitive value) instead of the real string.
variable "db_password" {
description = "Master password for the database"
type = string
sensitive = true
}
Be clear-eyed about what this does and does not do. sensitive stops the value from being printed to your terminal and CI logs, which is genuinely useful. It does not encrypt anything, and it does not keep the value out of state - the password is stored in plain text in your terraform.tfstate. Real protection comes from a remote backend with encryption at rest, tight access control on that state, and ideally pulling secrets from a real secrets manager at apply time rather than passing them as variables at all. Treat sensitive as "do not leak it to the console," not "it is safe now."
Output values: exposing what Terraform computed
Half of what Terraform creates is values you did not know in advance: the public IP of an instance, the DNS name of a load balancer, the ARN of a role, a generated password. Output values are how a configuration surfaces those computed results - both to you at the command line and, more importantly, to other configurations and modules.
output "instance_ip" {
description = "Public IP of the web instance"
value = aws_instance.web.public_ip
}
output "db_endpoint" {
description = "Connection endpoint for the database"
value = aws_db_instance.main.endpoint
}
output "db_password" {
description = "Generated database password"
value = random_password.db.result
sensitive = true
}
After terraform apply, outputs print at the end, and you can query them any time with terraform output or terraform output -raw instance_ip (raw strips the quotes, handy for scripts). Mark an output sensitive = true and it is redacted in the CLI just like a sensitive variable - do this for anything secret, because outputs are the values most likely to get pasted into a chat or a log.
Outputs matter most as the connective tissue between configurations. A module's outputs are its return values: the parent that calls the module reads them as module.<name>.<output>. And with remote state, one configuration can read another's outputs through a terraform_remote_state data source - your networking stack outputs a vpc_id, and your application stack consumes it. This is how you compose infrastructure out of separate, independently-applied pieces instead of one giant configuration. Outputs are covered further in the modules guide.
Local values: naming an expression once
A local value (locals) is a named expression you compute once and reuse. Where a variable is an input from the outside, a local is derived inside the configuration - usually from variables, resource attributes, or other locals. The point is DRY: if you find yourself writing the same expression in three places, make it a local.
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })
}
resource "aws_s3_bucket" "assets" {
bucket = "${local.name_prefix}-assets"
tags = local.common_tags
}
You reference a local as local.<name>. The win is obvious the moment you need to change something: rename the project or add a tag once in the locals block, and every resource that uses it updates together. Without locals, that same change is a find-and-replace across the whole file, and the one place you miss becomes a subtle drift.
Local vs variable: which do you use?
They look similar but answer different questions.
- Use a variable when the value comes from outside the configuration - something the caller sets, that differs between environments, that you want on the CLI or in a
.tfvarsfile. Variables are the public interface. - Use a local when the value is derived inside the configuration - a computed string, a merged map, a repeated expression. Locals are private internal helpers; a caller cannot set them.
If you catch yourself giving a variable a default that is really "project name plus environment glued together," that is a local wearing the wrong hat. Inputs are variables; anything you build out of those inputs is a local.
Variable types: the full toolbox
Terraform's type system is small but expressive, and using it well is what makes a module hard to misuse. The primitives are string, number, and bool. The collections are:
list(T)- an ordered sequence of the same type, e.g.list(string)for["us-east-1a", "us-east-1b"].map(T)- key-value pairs with string keys and values of typeT, e.g.map(string)for a set of tags.set(T)- like a list but unordered and unique.object({...})- a structured value with named, typed fields. This is how you accept a whole config block as one variable.
The object type is the one that turns a messy pile of flat variables into a clean interface. Here is a realistic example that ties the primitives and collections together - a single variable describing a service:
variable "service" {
description = "Configuration for one deployable service"
type = object({
name = string
instance_type = string
replicas = number
public = bool
zones = list(string)
tags = map(string)
})
default = {
name = "web"
instance_type = "t3.micro"
replicas = 3
public = false
zones = ["us-east-1a", "us-east-1b"]
tags = { team = "platform" }
}
}
And the caller sets it as one structured value in a .tfvars file:
# prod.tfvars
service = {
name = "api"
instance_type = "t3.large"
replicas = 6
public = true
zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
tags = { team = "platform", tier = "critical" }
}
Inside the configuration you reach into it with dot and bracket access - var.service.replicas, var.service.zones[0], var.service.tags["team"]. Typing the object means Terraform rejects a config that is missing replicas or passes a string where a number belongs, before a single resource is touched. The alternative - six loose variables that the caller has to remember to set consistently - is exactly the kind of thing that produces a broken environment at 2am. Give your inputs real types and you push whole classes of mistakes to plan time, where they are cheap.
Putting it together
Variables, outputs, and locals are the three constructs that make Terraform reusable. Variables are the inputs - typed, described, validated, and settable through a precedence chain that runs defaults -> terraform.tfvars -> *.auto.tfvars -> TF_VAR_ env vars -> -var on the CLI, last-write-wins. Mark the secret ones sensitive to keep them out of logs, but remember that is redaction, not encryption. Outputs expose what Terraform computed, both for you to read and for other configurations and modules to consume - they are the return values that let you compose infrastructure from separate pieces. Locals name derived expressions once so a change happens in one place instead of ten. Get the types right on your variables and most misuse fails at plan time, which is the whole game: catch it before it becomes real. From here, the natural next step is packaging this up for reuse, which is what Terraform modules are for.