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.
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.
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 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.
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=stagingFor 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.
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.
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.
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 scriptsNote that sensitive outputs appear unredacted in -json, so avoid printing the whole document into a CI log.
| 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 |
| 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 |
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.
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.
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.
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 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.
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.
Declare Terraform input variables with types, defaults and validation, then set them via tfvars files, CLI flags and environment variables, including secrets.
Learn how Terraform local values work, when to choose locals over input variables or data sources, and how a locals block removes repeated expressions.
Declare Terraform output values, mark them sensitive, read them with the terraform output command and JSON, expose module outputs and use them in CI/CD.