Laptop to Production

Variables, locals and outputs

One set of code for every environment.

Time
45 minutes
Level
Beginner

Part 1: The problem

QuickCart, the day stg arrived

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.

Key point: Variables are what change between environments. Locals are what you work out from them. Outputs are what you hand back.

The four pieces

PieceWhere it livesWhat it’s for
Input variablevariable blocks, usually in variables.tfA value the person running Terraform chooses
Variable valuesA .tfvars file, or the command lineThe actual value for this environment
Local valueA locals block, usually in locals.tfA value worked out once inside the code and reused, like a name prefix
Outputoutput blocks, usually in outputs.tfA 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.

variables.tf
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     = {}
}
SettingWhat it doesIf you leave it outWhen you’d change it
variable "environment"Declares a variable. Code reads it as var.environmentUsing var.environment anywhere fails with Reference to undeclared input variable—
descriptionA sentence saying what the value is for. Shown when Terraform asks for a value, and in error messagesNobody knows what to pass without reading the codeWhenever the meaning changes
typeWhat kind of value is allowed: string, number, bool, list(string), set(string), map(string) or object({...}). Terraform converts or rejects anything elseAny type is accepted, so "five" for a number only fails later, somewhere confusingRarely. Changing a type breaks everyone who sets it
defaultThe value used when nobody sets one. A variable with no default is requiredThe variable is required: Terraform asks for it, or fails in a pipelineGive defaults to things with a sensible usual value. Never default environment — it should always be a deliberate choice
validation → conditionAn expression that must be true for the value to be accepted. contains(list, value) checks membership; can(regex(...)) checks a patternAny value of the right type is accepted, including production or -1When the rules change, such as adding a prod environment
validation → error_messageWhat Terraform says when the condition is false. Write it as a full sentenceRequired inside validationTo make the message clearer
sensitiveHide the value in plan and apply output (shown in the example below)The value is printed wherever it appears in a planSet it on anything secret
nullableWhether null is an accepted value. Defaults to truenull 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:

TypeLooks likeUse it for
list(string)["a", "b", "a"] — ordered, repeats allowedThings where order matters
set(string)["a", "b"] — no order, no repeatsA group of names, like the queues in Loops and lifecycle
map(string){ Owner = "platform-team" } — keys and valuesTags, or a lookup table
object({...}){ cpu = 256, memory = 512 } — named fields, each with its own typeA group of settings that belong together

Setting the values

A .tfvars file is just a list of name = value lines. One per environment:

dev.tfvars
environment    = "dev"
images_to_keep = 5
stg.tfvars
environment    = "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.

ReadWhere the value comes fromExample
1st — weakestThe default in the variable blockdefault = 10
2ndAn environment variable called TF_VAR_ plus the variable’s nameexport TF_VAR_images_to_keep=15
3rdA file called exactly terraform.tfvars, loaded automaticallyimages_to_keep = 20
4thterraform.tfvars.json, loaded automatically{"images_to_keep": 20}
5thAny file ending .auto.tfvars or .auto.tfvars.json, loaded automatically in alphabetical orderteam.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.tf
locals {
  name_prefix = "${var.project}-${var.environment}"

  common_tags = merge(
    {
      Project     = var.project
      Environment = var.environment
      ManagedBy   = "terraform"
    },
    var.extra_tags,
  )
}
SettingWhat it doesIf you leave it outWhen you’d change it
localsA block of named values, read in code as local.name_prefix (note: local, singular)——
name_prefixJoins the project and environment with a hyphen. ${...} inside a string inserts a valueEvery resource builds its own name, and one day two of them do it differentlyIf your naming standard changes — in one place
common_tagsThe 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 setTags are typed out on each resource, and some get forgottenTo 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

main.tf
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"
      }
    }]
  })
}
SettingWhat it doesIf you leave it outWhen you’d change it
required_versionRefuse to run on Terraform older than 1.10An old Terraform may fail later with a confusing errorWhen you rely on a newer feature
required_providers → awsUse the hashicorp/aws plugin, any 6.x versionTerraform picks the newest, which may one day include breaking changesWhen you deliberately upgrade a major version
provider "aws" → regionBuild in the region the variable saysTerraform uses the region from your AWS login settingsSet it through var.region, not here
default_tags → tagsPut these tags on every resource this provider creates. Terraform shows the result as tags_all in the planEach resource needs its own tags, and new ones get forgottenRarely. Change common_tags instead
aws_ecr_repository "orders" → nameThe repository’s name in AWS, built from the prefix: quickcart-dev-ordersRequiredChanging it replaces the repository, as you’ll see in the lab
image_tag_mutabilityIMMUTABLE: a tag like v1 can never point at a different imageDefaults to MUTABLERarely. Storing images: ECR explains why
force_deleteLet terraform destroy delete the repository even if it holds imagesDestroy fails while any image is insideSet to false for production
image_scanning_configuration → scan_on_pushCheck each pushed image for known vulnerabilitiesNo scanning on pushKeep it on
aws_ecr_lifecycle_policy → repositoryWhich repository the clean-up rule belongs to. Referring to aws_ecr_repository.orders.name also tells Terraform to create the repository firstRequiredNever
policyThe rule itself, as JSON. jsonencode() turns Terraform’s map into JSON text, so you don’t hand-write quotes and commasRequired—
rulePriorityRules are applied lowest number firstRequired by AWSWhen you add more rules
selection → tagStatus, countType, countNumberWhich 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 upRequired by AWSChange the variable, not the code
action → typeexpire: delete the images that matchRequired by AWS—

Outputs

outputs.tf
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
}
SettingWhat it doesIf you leave it outWhen you’d change it
output "repository_url"Names a value to print after apply, and to show with terraform outputYou’d dig the address out of the console—
descriptionWhat the value is, for whoever reads itNothing breaks; readers guessWhenever the meaning changes
valueWhat to output. repository_url is filled in by AWS after creationRequired—
sensitiveShow <sensitive> instead of the valuePrinted in fullRequired 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:

sensitive-example.tf
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
}
SettingWhat it doesIf you leave it outWhen you’d change it
sensitive = truePlans and output show (sensitive value) instead of the secretThe secret is printed in every plan — and plans get pasted into pull requestsAlways on secrets
ephemeral = trueTerraform 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 argumentsThe value is stored in state in plain text, even with sensitiveAlways on secrets, where the resource supports it. You must set it on every run, including destroy
aws_ssm_parameter → nameWhere the secret lives in Parameter StoreRequired—
descriptionA note on what it’s forNo description in the console—
typeSecureString: stored encrypted by AWSRequired—
value_woA 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_versionTerraform can’t see a write-only value, so it can’t tell when it changes. Raise this number to send a new valueThe new secret is never sentAdd 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.

Set the value in
Command line order

Read last, wins

  1. -var on the command line-var images_to_keep=50not set
  2. -var-file on the command line-var-file=stg.tfvars (images_to_keep = 30)not set
  3. team.auto.tfvarsimages_to_keep = 25not set
  4. terraform.tfvarsimages_to_keep = 20not set
  5. Environment variableexport TF_VAR_images_to_keep=15not set
  6. Default in variables.tfdefault = 10wins

Read first, loses

$ terraform plan
var.images_to_keep = 10
Which value winsOnly one place sets it, so Default in variables.tf decides: 10.

What 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.

  1. Make the folder

    Terminal
    mkdir quickcart-vars
    PartWhat it means
    mkdirmake a directory
    quickcart-varsits name, inside the folder you’re in now
    Terminal
    cd quickcart-vars
    PartWhat it means
    cdchange directory — every later command runs in here
    quickcart-varsthe folder you just made
  2. Write the four files

    Create these four files in the folder. Each one is explained line by line in the idea, above.

    variables.tf
    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     = {}
    }
    locals.tf
    locals {
      name_prefix = "${var.project}-${var.environment}"
    
      common_tags = merge(
        {
          Project     = var.project
          Environment = var.environment
          ManagedBy   = "terraform"
        },
        var.extra_tags,
      )
    }
    main.tf
    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"
          }
        }]
      })
    }
    outputs.tf
    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
    }
  3. Write one values file per environment

    dev.tfvars
    environment    = "dev"
    images_to_keep = 5
    stg.tfvars
    environment    = "stg"
    images_to_keep = 20
    
    extra_tags = {
      Owner = "platform-team"
    }

    Then set up the folder:

    Terminal
    terraform init
    PartWhat it means
    terraform initdownload the AWS provider and prepare this folder
    You should see
    Terraform has been successfully initialized!
  4. See validation catch a bad value

    Terminal
    terraform plan -var environment=production
    PartWhat 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 anything
    Planning 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.

  5. Plan stg, without building it

    Terminal
    terraform plan -var-file=stg.tfvars
    PartWhat 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, countNumber is 20, and tags_all has stg’s extra Owner tag. None of that is written in main.tf. It all came from stg.tfvars.

    You don’t apply this. In a real project each environment keeps its own state, which Environments and regions sets up.

  6. Apply dev and read the outputs

    Terminal
    terraform apply -var-file=dev.tfvars
    PartWhat 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-orders and countNumber = 5, then type yes.

    You should see
    aws_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:

    Terminal
    terraform output -raw repository_url
    PartWhat 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 see
    111122223333.dkr.ecr.us-east-1.amazonaws.com/quickcart-dev-orders

    In the console, open ECR → Private registry → Repositories → quickcart-dev-orders. Its Tags show Project, Environment and ManagedBy, even though the resource block has no tags line. That’s default_tags.

  7. Watch precedence decide

    First, a command-line value beats the file:

    Terminal
    terraform plan -var-file=dev.tfvars -var images_to_keep=3
    PartWhat 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:

    Terminal
    TF_VAR_images_to_keep=40 terraform plan -var-file=dev.tfvars
    PartWhat 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_keep
    You should see
    No 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.

  8. See a rename replace the repository

    Ask what would happen if this same state were pointed at stg’s values:

    Terminal
    terraform plan -var-file=stg.tfvars
    PartWhat 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.

  9. Clean-up step: Clean up

    Terminal
    terraform destroy -var-file=dev.tfvars
    PartWhat 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. environment is required, so destroy needs it too

    Type yes.

    You should see
    Destroy 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

On one card
ThingWhat 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 = trueHide the value in output. It’s still in state
ephemeral = trueKeep 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.tfvarsUse one environment’s values
-var name=valueSet one value on the command line
TF_VAR_nameSet a value from the environment — weaker than any tfvars file
Precedence, weakest firstdefault → TF_VAR_ → terraform.tfvars → *.auto.tfvars → -var / -var-file (later wins)
terraform output -raw xPrint one output, bare

Finished the build and the quiz? Mark it done to fill in its stop on the route map.