Variables, locals and outputs
One set of code for every environment.
Part 1: The problem
Maya’s Terraform built QuickCart’s image repository for dev. The team wanted a staging environment too, so she copied the folder, renamed it stg, and changed dev to stg wherever she spotted it.
She missed one. The first apply in the stg folder tried to create a repository called quickcart-dev-orders — which already existed — and AWS refused.
A month later it was quieter and worse. Leo had turned on image scanning in dev. Nobody copied the change to stg, because nobody remembered stg was a separate copy. The two environments now differed in ways nobody had chosen.
Copy-and-edit gives you two sets of code that slowly stop matching. The fix is one set of code, with the parts that differ pulled out as variables.
This lesson covers the four things that turn one file into code for every environment: variables, the files that set them, locals and outputs.
Part 2: The idea
Right now main.tf says name = "quickcart-dev-orders". The word dev is welded into the code. To build stg you’d have to change the code.
Instead, the code says the name is the project, a hyphen, the environment, then “-orders”, and something outside the code says which environment this is.
The four pieces
| Piece | Where it lives | What it’s for |
|---|---|---|
| Input variable | variable blocks, usually in variables.tf | A value the person running Terraform chooses |
| Variable values | A .tfvars file, or the command line | The actual value for this environment |
| Local value | A locals block, usually in locals.tf | A value worked out once inside the code and reused, like a name prefix |
| Output | output blocks, usually in outputs.tf | A value Terraform prints after apply, and that other code can read |
The file names are a convention, not a rule. Terraform reads every .tf file in the folder as if they were one file.
Declaring variables
Here is the variables.tf you’ll build in the lab. Every setting inside a variable block is optional, but a good one has most of them.
variable "project" {
description = "Short name put at the start of everything this code creates"
type = string
default = "quickcart"
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,20}$", var.project))
error_message = "The project must be 2 to 21 lowercase letters, digits or hyphens, starting with a letter."
}
}
variable "environment" {
description = "Which environment this is: dev, stg or prod"
type = string
validation {
condition = contains(["dev", "stg", "prod"], var.environment)
error_message = "The environment must be dev, stg or prod."
}
}
variable "region" {
description = "The AWS region to build in"
type = string
default = "us-east-1"
}
variable "images_to_keep" {
description = "How many images the repository keeps before deleting the oldest"
type = number
default = 10
validation {
condition = var.images_to_keep >= 1 && var.images_to_keep <= 100
error_message = "Keep between 1 and 100 images."
}
}
variable "extra_tags" {
description = "Any extra tags to add to every resource"
type = map(string)
default = {}
}| Setting | What it does | If you leave it out | When you’d change it |
|---|---|---|---|
variable "environment" | Declares a variable. Code reads it as var.environment | Using var.environment anywhere fails with Reference to undeclared input variable | — |
description | A sentence saying what the value is for. Shown when Terraform asks for a value, and in error messages | Nobody knows what to pass without reading the code | Whenever the meaning changes |
type | What kind of value is allowed: string, number, bool, list(string), set(string), map(string) or object({...}). Terraform converts or rejects anything else | Any type is accepted, so "five" for a number only fails later, somewhere confusing | Rarely. Changing a type breaks everyone who sets it |
default | The value used when nobody sets one. A variable with no default is required | The variable is required: Terraform asks for it, or fails in a pipeline | Give defaults to things with a sensible usual value. Never default environment — it should always be a deliberate choice |
validation → condition | An expression that must be true for the value to be accepted. contains(list, value) checks membership; can(regex(...)) checks a pattern | Any value of the right type is accepted, including production or -1 | When the rules change, such as adding a prod environment |
validation → error_message | What Terraform says when the condition is false. Write it as a full sentence | Required inside validation | To make the message clearer |
sensitive | Hide the value in plan and apply output (shown in the example below) | The value is printed wherever it appears in a plan | Set it on anything secret |
nullable | Whether null is an accepted value. Defaults to true | null is allowed and means “use the default” | Set false on a variable that must never be empty |
The types that hold several values are worth knowing apart:
| Type | Looks like | Use it for |
|---|---|---|
list(string) | ["a", "b", "a"] — ordered, repeats allowed | Things where order matters |
set(string) | ["a", "b"] — no order, no repeats | A group of names, like the queues in Loops and lifecycle |
map(string) | { Owner = "platform-team" } — keys and values | Tags, or a lookup table |
object({...}) | { cpu = 256, memory = 512 } — named fields, each with its own type | A group of settings that belong together |
Setting the values
A .tfvars file is just a list of name = value lines. One per environment:
environment = "dev"
images_to_keep = 5environment = "stg"
images_to_keep = 20
extra_tags = {
Owner = "platform-team"
}Anything a file doesn’t mention keeps its default. You pick a file with -var-file=dev.tfvars.
When several places set the same variable
There are six ways to set a value, and they can all be used at once. Terraform reads them in a fixed order, and each one overwrites the one before. The last one read wins.
| Read | Where the value comes from | Example |
|---|---|---|
| 1st — weakest | The default in the variable block | default = 10 |
| 2nd | An environment variable called TF_VAR_ plus the variable’s name | export TF_VAR_images_to_keep=15 |
| 3rd | A file called exactly terraform.tfvars, loaded automatically | images_to_keep = 20 |
| 4th | terraform.tfvars.json, loaded automatically | {"images_to_keep": 20} |
| 5th | Any file ending .auto.tfvars or .auto.tfvars.json, loaded automatically in alphabetical order | team.auto.tfvars |
| 6th — strongest | -var and -var-file on the command line, in the order you typed them. The later one wins | -var-file=stg.tfvars -var images_to_keep=50 |
Locals: working things out once
Every name in QuickCart’s code starts with the project and the environment, like quickcart-dev. Rather than writing "${var.project}-${var.environment}" in twenty places, you work it out once:
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = merge(
{
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
},
var.extra_tags,
)
}| Setting | What it does | If you leave it out | When you’d change it |
|---|---|---|---|
locals | A block of named values, read in code as local.name_prefix (note: local, singular) | — | — |
name_prefix | Joins the project and environment with a hyphen. ${...} inside a string inserts a value | Every resource builds its own name, and one day two of them do it differently | If your naming standard changes — in one place |
common_tags | The tags every resource gets. merge() combines maps; where both have the same key, the later map wins, so extra_tags can add to or override the standard set | Tags are typed out on each resource, and some get forgotten | To add a tag your organisation requires, like a cost centre |
Variable or local? If the person running Terraform should choose it, it’s a variable. If it follows from other values by a rule, it’s a local. A local can’t be set from outside, which is the point: nobody can pass in a name that breaks the naming rule.
Using them, with default tags
terraform {
required_version = ">= 1.10.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = local.common_tags
}
}
resource "aws_ecr_repository" "orders" {
name = "${local.name_prefix}-orders"
image_tag_mutability = "IMMUTABLE"
force_delete = true
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_ecr_lifecycle_policy" "orders" {
repository = aws_ecr_repository.orders.name
policy = jsonencode({
rules = [{
rulePriority = 1
description = "Keep only the newest images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = var.images_to_keep
}
action = {
type = "expire"
}
}]
})
}| Setting | What it does | If you leave it out | When you’d change it |
|---|---|---|---|
required_version | Refuse to run on Terraform older than 1.10 | An old Terraform may fail later with a confusing error | When you rely on a newer feature |
required_providers → aws | Use the hashicorp/aws plugin, any 6.x version | Terraform picks the newest, which may one day include breaking changes | When you deliberately upgrade a major version |
provider "aws" → region | Build in the region the variable says | Terraform uses the region from your AWS login settings | Set it through var.region, not here |
default_tags → tags | Put these tags on every resource this provider creates. Terraform shows the result as tags_all in the plan | Each resource needs its own tags, and new ones get forgotten | Rarely. Change common_tags instead |
aws_ecr_repository "orders" → name | The repository’s name in AWS, built from the prefix: quickcart-dev-orders | Required | Changing it replaces the repository, as you’ll see in the lab |
image_tag_mutability | IMMUTABLE: a tag like v1 can never point at a different image | Defaults to MUTABLE | Rarely. Storing images: ECR explains why |
force_delete | Let terraform destroy delete the repository even if it holds images | Destroy fails while any image is inside | Set to false for production |
image_scanning_configuration → scan_on_push | Check each pushed image for known vulnerabilities | No scanning on push | Keep it on |
aws_ecr_lifecycle_policy → repository | Which repository the clean-up rule belongs to. Referring to aws_ecr_repository.orders.name also tells Terraform to create the repository first | Required | Never |
policy | The rule itself, as JSON. jsonencode() turns Terraform’s map into JSON text, so you don’t hand-write quotes and commas | Required | — |
rulePriority | Rules are applied lowest number first | Required by AWS | When you add more rules |
selection → tagStatus, countType, countNumber | Which images the rule looks at (any), and “more than N images”. N comes from var.images_to_keep — this is where dev’s 5 and stg’s 20 end up | Required by AWS | Change the variable, not the code |
action → type | expire: delete the images that match | Required by AWS | — |
Outputs
output "repository_name" {
description = "The repository's name in AWS"
value = aws_ecr_repository.orders.name
}
output "repository_url" {
description = "The address to push images to"
value = aws_ecr_repository.orders.repository_url
}| Setting | What it does | If you leave it out | When you’d change it |
|---|---|---|---|
output "repository_url" | Names a value to print after apply, and to show with terraform output | You’d dig the address out of the console | — |
description | What the value is, for whoever reads it | Nothing breaks; readers guess | Whenever the meaning changes |
value | What to output. repository_url is filled in by AWS after creation | Required | — |
sensitive | Show <sensitive> instead of the value | Printed in full | Required when the value comes from anything sensitive, as the next mistake shows |
Outputs are also how other code reads from this code: a module’s outputs are the only part of it the caller can see.
Secrets: sensitive, and keeping them out of state
Sometimes a value really is secret, like the webhook address QuickCart’s alarms post to. This file isn’t part of the lab — it’s an example of the pattern:
variable "alerts_webhook_url" {
description = "Secret address that alarms post to. Set it with TF_VAR_alerts_webhook_url, never in a file"
type = string
sensitive = true
ephemeral = true
}
resource "aws_ssm_parameter" "alerts_webhook" {
name = "/${local.name_prefix}/alerts-webhook-url"
description = "Where alarms are sent"
type = "SecureString"
value_wo = var.alerts_webhook_url
value_wo_version = 1
}| Setting | What it does | If you leave it out | When you’d change it |
|---|---|---|---|
sensitive = true | Plans and output show (sensitive value) instead of the secret | The secret is printed in every plan — and plans get pasted into pull requests | Always on secrets |
ephemeral = true | Terraform 1.10 and later: the value is used during the run and never written to the state or a saved plan. It can only be passed to places that accept ephemeral values, like write-only arguments | The value is stored in state in plain text, even with sensitive | Always on secrets, where the resource supports it. You must set it on every run, including destroy |
aws_ssm_parameter → name | Where the secret lives in Parameter Store | Required | — |
description | A note on what it’s for | No description in the console | — |
type | SecureString: stored encrypted by AWS | Required | — |
value_wo | A write-only argument: sent to AWS, never stored in state. Plans show (write-only attribute) | You’d use value, which is stored in state | — |
value_wo_version | Terraform can’t see a write-only value, so it can’t tell when it changes. Raise this number to send a new value | The new secret is never sent | Add 1 each time the secret changes |
Part 3: See it
A variable can be set in six places at once. Turn sources on and off, and see which value Terraform ends up using. Try the environment variable together with terraform.tfvars, and try swapping the order of -var and -var-file.
Read last, wins
- -var on the command line
-var images_to_keep=50not set - -var-file on the command line
-var-file=stg.tfvars (images_to_keep = 30)not set - team.auto.tfvars
images_to_keep = 25not set - terraform.tfvars
images_to_keep = 20not set - Environment variable
export TF_VAR_images_to_keep=15not set - Default in variables.tf
default = 10wins
Read first, loses
$ terraform planWhat to notice: Terraform never warns you that one value overrode another. It reads the sources in a fixed order and the last one wins. When a value surprises you, walk up this list.
Part 4: Do it
Free — an empty ECR repository and its clean-up rule. ECR charges for stored images, and this lab stores none.
You’ll build one set of code, plan it for two environments, watch validation and precedence at work, then apply dev and read its outputs. About 30 minutes.
You need Terraform 1.10 or later and a working AWS login in your terminal. Set up your computer covers both.
Make the folder
Terminalmkdir quickcart-varsPart What it means mkdirmake a directory quickcart-varsits name, inside the folder you’re in now Terminalcd quickcart-varsPart What it means cdchange directory — every later command runs in here quickcart-varsthe folder you just made Write the four files
Create these four files in the folder. Each one is explained line by line in the idea, above.
variables.tfvariable "project" { description = "Short name put at the start of everything this code creates" type = string default = "quickcart" validation { condition = can(regex("^[a-z][a-z0-9-]{1,20}$", var.project)) error_message = "The project must be 2 to 21 lowercase letters, digits or hyphens, starting with a letter." } } variable "environment" { description = "Which environment this is: dev, stg or prod" type = string validation { condition = contains(["dev", "stg", "prod"], var.environment) error_message = "The environment must be dev, stg or prod." } } variable "region" { description = "The AWS region to build in" type = string default = "us-east-1" } variable "images_to_keep" { description = "How many images the repository keeps before deleting the oldest" type = number default = 10 validation { condition = var.images_to_keep >= 1 && var.images_to_keep <= 100 error_message = "Keep between 1 and 100 images." } } variable "extra_tags" { description = "Any extra tags to add to every resource" type = map(string) default = {} }locals.tflocals { name_prefix = "${var.project}-${var.environment}" common_tags = merge( { Project = var.project Environment = var.environment ManagedBy = "terraform" }, var.extra_tags, ) }main.tfterraform { required_version = ">= 1.10.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } } provider "aws" { region = var.region default_tags { tags = local.common_tags } } resource "aws_ecr_repository" "orders" { name = "${local.name_prefix}-orders" image_tag_mutability = "IMMUTABLE" force_delete = true image_scanning_configuration { scan_on_push = true } } resource "aws_ecr_lifecycle_policy" "orders" { repository = aws_ecr_repository.orders.name policy = jsonencode({ rules = [{ rulePriority = 1 description = "Keep only the newest images" selection = { tagStatus = "any" countType = "imageCountMoreThan" countNumber = var.images_to_keep } action = { type = "expire" } }] }) }outputs.tfoutput "repository_name" { description = "The repository's name in AWS" value = aws_ecr_repository.orders.name } output "repository_url" { description = "The address to push images to" value = aws_ecr_repository.orders.repository_url }Write one values file per environment
dev.tfvarsenvironment = "dev" images_to_keep = 5stg.tfvarsenvironment = "stg" images_to_keep = 20 extra_tags = { Owner = "platform-team" }Then set up the folder:
Terminalterraform initPart What it means terraform initdownload the AWS provider and prepare this folder You should seeTerraform has been successfully initialized!See validation catch a bad value
Terminalterraform plan -var environment=productionPart What it means terraform planwork out what would change, without changing anything -var environment=productionset one variable on the command line, to a value the validation doesn’t allow Terraform stops before planning anythingPlanning failed. Terraform encountered an error while generating this plan. Error: Invalid value for variable on variables.tf line 12: 12: variable "environment" { ├──────────────── │ var.environment is "production" The environment must be dev, stg or prod. This was checked by the validation rule at variables.tf:16,3-13.Your own error message, before anything touched AWS. The line numbers point at your file.
Plan stg, without building it
Terminalterraform plan -var-file=stg.tfvarsPart What it means terraform planwork out what would change -var-file=stg.tfvarsread the variable values from stg’s file The important part of the plan# aws_ecr_lifecycle_policy.orders will be created + resource "aws_ecr_lifecycle_policy" "orders" { + id = (known after apply) + policy = jsonencode( { + rules = [ + { + action = { + type = "expire" } + description = "Keep only the newest images" + rulePriority = 1 + selection = { + countNumber = 20 + countType = "imageCountMoreThan" + tagStatus = "any" } }, ] } ) + region = "us-east-1" + registry_id = (known after apply) + repository = "quickcart-stg-orders" } # aws_ecr_repository.orders will be created + resource "aws_ecr_repository" "orders" { + arn = (known after apply) + force_delete = true + id = (known after apply) + image_tag_mutability = "IMMUTABLE" + name = "quickcart-stg-orders" + region = "us-east-1" + registry_id = (known after apply) + repository_url = (known after apply) + tags_all = { + "Environment" = "stg" + "ManagedBy" = "terraform" + "Owner" = "platform-team" + "Project" = "quickcart" } + image_scanning_configuration { + scan_on_push = true } } Plan: 2 to add, 0 to change, 0 to destroy. Changes to Outputs: + repository_name = "quickcart-stg-orders" + repository_url = (known after apply)Check three things: the name is
quickcart-stg-orders,countNumberis 20, andtags_allhas stg’s extraOwnertag. None of that is written inmain.tf. It all came fromstg.tfvars.You don’t apply this. In a real project each environment keeps its own state, which Environments and regions sets up.
Apply dev and read the outputs
Terminalterraform apply -var-file=dev.tfvarsPart What it means terraform applyplan, show the plan, and carry it out once you type yes-var-file=dev.tfvarsuse dev’s values Check the plan says
quickcart-dev-ordersandcountNumber = 5, then typeyes.You should seeaws_ecr_repository.orders: Creating... aws_ecr_repository.orders: Creation complete after 1s [id=quickcart-dev-orders] aws_ecr_lifecycle_policy.orders: Creating... aws_ecr_lifecycle_policy.orders: Creation complete after 0s [id=quickcart-dev-orders] Apply complete! Resources: 2 added, 0 changed, 0 destroyed. Outputs: repository_name = "quickcart-dev-orders" repository_url = "111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-dev-orders"Your account number appears in place of
111122223333. To print one output on its own, without quotes — handy in scripts:Terminalterraform output -raw repository_urlPart What it means terraform outputshow the outputs saved in the state, without planning anything -rawprint just the value, with no quotes or name repository_urlwhich output You should see111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-dev-ordersIn the console, open ECR → Private registry → Repositories → quickcart-dev-orders. Its Tags show
Project,EnvironmentandManagedBy, even though the resource block has notagsline. That’sdefault_tags.Watch precedence decide
First, a command-line value beats the file:
Terminalterraform plan -var-file=dev.tfvars -var images_to_keep=3Part What it means -var-file=dev.tfvarsdev’s values, including images_to_keep = 5-var images_to_keep=3one more value, typed later on the same line, so it wins You should see# aws_ecr_lifecycle_policy.orders must be replaced -/+ resource "aws_ecr_lifecycle_policy" "orders" { ~ id = "quickcart-dev-orders" -> (known after apply) ~ policy = jsonencode( ~ { ~ rules = [ ~ { ~ selection = { ~ countNumber = 5 -> 3 # (2 unchanged attributes hidden) } # (3 unchanged attributes hidden) }, ] } # forces replacement ) ~ registry_id = "111122223333" -> (known after apply) # (2 unchanged attributes hidden) } Plan: 1 to add, 0 to change, 1 to destroy.countNumber = 5 -> 3: the command line won.-/+means AWS can’t edit a lifecycle policy in place, so Terraform replaces it — harmless for a policy. Don’t apply it.Now an environment variable, which you might expect to win:
TerminalTF_VAR_images_to_keep=40 terraform plan -var-file=dev.tfvarsPart What it means TF_VAR_images_to_keep=40set an environment variable for this one command only terraform planplan as usual -var-file=dev.tfvarsdev’s file, which also sets images_to_keepYou should seeNo changes. Your infrastructure matches the configuration.The environment variable said 40. The file said 5 and was read later, so 5 won and nothing changed. That’s the surprise from the precedence table.
See a rename replace the repository
Ask what would happen if this same state were pointed at stg’s values:
Terminalterraform plan -var-file=stg.tfvarsPart What it means -var-file=stg.tfvarsstg’s values, against the state that holds dev’s repository The key lines# aws_ecr_repository.orders must be replaced -/+ resource "aws_ecr_repository" "orders" { ~ name = "quickcart-dev-orders" -> "quickcart-stg-orders" # forces replacement Plan: 2 to add, 0 to change, 2 to destroy.Terraform would destroy dev’s repository to build stg’s. One folder, one state, two environments means each apply replaces the other. Don’t apply it. That’s why each environment gets its own state — the subject of Environments and regions.
Clean-up step: Clean up
Terminalterraform destroy -var-file=dev.tfvarsPart What it means terraform destroyplan the removal of everything in the state, and carry it out once you type yes-var-file=dev.tfvarsthe same values you applied with. environmentis required, so destroy needs it tooType
yes.You should seeDestroy complete! Resources: 2 destroyed.
Part 5: Check yourself
Maya wants dev to keep 5 images and stg to keep 20, from the same code. Where do those two numbers belong?
terraform.tfvars says images_to_keep = 20. Leo runs `export TF_VAR_images_to_keep=40`, then `terraform plan`. Which value does Terraform use?
What does `sensitive = true` on a variable do?
A variable has a validation block that only allows dev, stg or prod. Someone runs plan with environment = "production". When does it fail?
Why put tags in the provider's default_tags rather than on each resource?
Part 6: Cheat sheet
| Thing | What it does |
|---|---|
variable "x" { type, default, description } | Declare an input. No default means required |
validation { condition, error_message } | Reject bad values before anything is planned |
sensitive = true | Hide the value in output. It’s still in state |
ephemeral = true | Keep a variable out of state and plan files (1.10+) |
locals { name_prefix = "${var.project}-${var.environment}" } | Work a value out once; read it as local.name_prefix |
default_tags { tags = local.common_tags } | Tag every resource the provider creates |
output "x" { value, description } | Print a value after apply; how modules hand values back |
-var-file=dev.tfvars | Use one environment’s values |
-var name=value | Set one value on the command line |
TF_VAR_name | Set a value from the environment — weaker than any tfvars file |
| Precedence, weakest first | default → TF_VAR_ → terraform.tfvars → *.auto.tfvars → -var / -var-file (later wins) |
terraform output -raw x | Print one output, bare |