Terraform Basics #3 Variables, Outputs, and Locals: Three Tools for Removing Hardcoded Values
The code up through the previous post had the bucket name and the region written as string literals. Fine for practice — but the moment you want two copies of the same setup, one for dev and one for prod, you find yourself copying the file and editing strings. Those two copies soon start drifting apart, and the problems of the console-clicking days reappear on top of your code. The answer is to pull the values out of the code, and Terraform provides three tools for it: variables, outputs, and local values. This post covers all three.
variable: the entrance for values #
Variables are declared with a variable block. By convention they are collected in a variables.tf file.
variable "env" {
type = string
description = "Deployment environment name (dev, prod)"
}
variable "bucket_prefix" {
type = string
description = "Prefix for the bucket name"
default = "my-terraform"
}A declared variable is used as var.name.
resource "aws_s3_bucket" "hello" {
bucket = "${var.bucket_prefix}-${var.env}"
}The ${...} syntax for embedding a value inside a string is called interpolation. A variable with a default uses that value when none is supplied; a variable without one refuses to run until a value arrives. Beyond string, type can be number, bool, or collection types like list(string) and map(string), and a value of the wrong type is rejected at the plan stage.
The four paths for supplying a value #
There are four ways to give env — which has no default — a value.
- A
terraform.tfvarsfile: if a file with this name exists in the directory, it is read automatically. One line —env = "dev"— is enough. - The
-var-fileflag: pick a file explicitly, as interraform plan -var-file="prod.tfvars". This is the foundation of the real-world pattern of keeping one file per environment. - The
-varflag: supply values one at a time, as interraform plan -var="env=dev". - Environment variables: prefix the name with
TF_VAR_, as inTF_VAR_env=dev, and it is passed in from the shell environment. This is the usual approach in CI.
When paths overlap, the later one wins. Environment variables are the weakest, tfvars files come next, and -var on the command line is the strongest. If no path supplies a value, Terraform prompts for it interactively — but leaning on interactive input in a tool built for automation is a bad habit, so I recommend making tfvars files your default.
validation: block bad values at the door #
The kind of accident where production slips in instead of prod and skews the bucket name can be blocked right in the variable declaration.
variable "env" {
type = string
description = "Deployment environment name"
validation {
condition = contains(["dev", "prod"], var.env)
error_message = "env must be either dev or prod."
}
}A value outside the allowed list is rejected with the error_message before plan even starts. If a variable has constraints on its values, attaching a validation now prevents a mistake months later. For values like passwords you can add sensitive = true, which masks the value as (sensitive value) in plan output. But as we will see in #5, the value is stored as-is in the state file — remember that the masking protects only what appears on screen.
output: the exit for values #
To expose information about the resources you created, use an output block. By convention they go in outputs.tf.
output "bucket_name" {
description = "Name of the created bucket"
value = aws_s3_bucket.hello.bucket
}
output "bucket_arn" {
value = aws_s3_bucket.hello.arn
}Outputs print at the end of an apply, and you can view them again later with the terraform output command. Extract just the value — terraform output -raw bucket_name — and you can feed it into shell scripts. For now they only serve human readers, but once we cover modules in #8, outputs become the channel through which modules pass values to each other.
locals: naming internal computations #
If variables are values that come in from outside, local values are values computed inside and given a name.
locals {
bucket_name = "${var.bucket_prefix}-${var.env}"
common_tags = {
Env = var.env
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket" "hello" {
bucket = local.bucket_name
tags = local.common_tags
}References use local.name (note the singular: declared as locals, referenced as local). If you find yourself repeating the same composed expression or tag map across multiple resources, that is the signal to lift it into locals. The dividing line against variables is simple: if the user needs to change it, it is a variable; if it is composed inside the code, it is a local. Turning everything into a variable only piles more inputs onto whoever uses the code, so keep the entrance narrow and leave composition to locals — the code reads better for it.
Recap #
What we covered in this post:
- Variables are declared with a
variableblock and used asvar.name. Specifying a type filters out malformed values before plan - Values arrive through four paths — tfvars files,
-var-file,-var, andTF_VAR_environment variables — with tfvars files as the default choice - validation blocks out-of-range values at the door, and sensitive masks only the screen output. The state file keeps the value as-is
- output is the exit for information about created resources, and in the module era it becomes the channel between modules
- Values received from outside are variables; values composed inside are locals
The next post (#4 Data Sources and Expressions) goes in the opposite direction: data sources, which read information about existing resources Terraform did not create, and shaping values with conditionals and for expressions.