Command Palette

Search for a command to run...

Hectal

Mission 0.4 · Stage 0 — First Apply

Variables, Locals & Outputs — Make ShopLite Reusable

Goal: ShopLite's config driven by typed, validated variables, with consistent names built in locals, a random suffix for global uniqueness, and outputs other tools can read.

40 min Free 10 steps 3 break-it drills

By the end of this mission

  • Declare typed variables with defaults, descriptions, and validation
  • Know every way to set a variable and which one wins
  • Use locals to build names once, and random_id for unique names
  • Expose values with outputs and read them from scripts

Part 1

Understand it first

Input variables: the function parameters of a configuration

Think of a Terraform configuration as a function. VARIABLES are its parameters, RESOURCES are its body, and OUTPUTS are its return values. Hardcoded values like ap-south-1 or the bucket name make the code usable for exactly one environment. Variables let the same code build dev and prod with different inputs, which Stage 6 relies on.

Always give variables a type (string, number, bool, list(string), map(string), object({...})) and a description, so wrong input fails early with a clear message. Add validation blocks for business rules, such as 'environment must be dev, staging, or prod', so bad input fails at plan time instead of producing a wrongly named production resource.

Where variable values come from — and which wins

From lowest to highest precedence: the variable's default, then environment variables TF_VAR_<name>, then terraform.tfvars, then *.auto.tfvars files (alphabetically), then -var-file=..., and finally -var name=value on the command line. Later sources override earlier ones.

In practice, commit a terraform.tfvars (or per-environment .tfvars files) with non-secret values, use TF_VAR_... in CI for anything injected by the pipeline, and keep secrets out of tfvars entirely (Stage 4 uses Secrets Manager). A variable with no default and no value makes Terraform prompt interactively, which breaks CI, so provide every value explicitly.

Locals: compute once, use everywhere

LOCALS are named expressions inside the configuration, not inputs. Use them to avoid repeating logic: name_prefix = "${var.project}-${var.environment}" is written once, and every resource name uses local.name_prefix. When the naming scheme changes, you change one line.

Rule of thumb: if a caller should be able to change it, make it a variable. If it's derived from other values or is an internal convention, make it a local.

Outputs, and random_id for stable uniqueness

OUTPUTS publish values after apply: the bucket name for a deploy script, a load balancer URL for a smoke test, a VPC ID for another Terraform configuration (Stage 6). terraform output -raw name prints a bare value that scripts can use. Outputs from a sensitive value must be marked sensitive = true. Note that this only hides them from terminal output; state still stores them in plain text.

random_id (from the hashicorp/random provider) generates random bytes ONCE, at creation, and stores them in state. Every later plan reuses the same value, so the bucket name stays stable. It isn't regenerated per run, which is exactly what you want for a globally unique yet permanent name.

Values flowing through the configurationdiagram
Rendering diagram…

Part 2

Your project after this mission · 8 files change

shoplite/
  • infra/
    • .terraform.lock.hclmodified
    • locals.tfnew
    • main.tfmodified
    • outputs.tfnew
    • providers.tfmodified
    • terraform.tfvarsnew
    • variables.tfnew
    • versions.tfmodified
  • .gitignore

Part 3

Build it, step by step

  1. 1

    Declare typed, validated variables

    Three inputs describe where and what we're deploying. The validation block runs at plan time; contains(...) is one of Terraform's built-in functions, and terraform console lets you try any function interactively.

    infra/variables.tfwhole filehcl
    variable "project" {
      description = "Short project name, used as a prefix for every resource name."
      type        = string
      default     = "shoplite"
    }
    
    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 "aws_region" {
      description = "AWS region to deploy into."
      type        = string
      default     = "ap-south-1"
    }
  2. 2

    Set values in terraform.tfvars

    Terraform loads terraform.tfvars automatically. It holds no secrets, so commit it. environment has no default, so without this file every plan would stop and prompt for it.

    infra/terraform.tfvarswhole filehcl
    environment = "dev"
  3. 3

    Add the random provider

    hashicorp/random generates values like IDs and passwords and stores them in state. Every provider you use goes in required_providers.

    infra/versions.tfwhole filehcl
    terraform {
      required_version = ">= 1.10"
    
      required_providers {
        aws = {
          source  = "hashicorp/aws"
          version = "~> 6.0"
        }
        random = {
          source  = "hashicorp/random"
          version = "~> 3.6"
        }
      }
    }
  4. 4

    Build names in locals

    random_id with byte_length = 2 gives four hex characters, such as 3f9a. The locals combine project, environment, and suffix, so every name follows one convention.

    infra/locals.tfwhole filehcl
    resource "random_id" "suffix" {
      byte_length = 2
    }
    
    locals {
      name_prefix = "${var.project}-${var.environment}"
    
      # Globally unique names (S3) get the random suffix; everything else uses name_prefix.
      assets_bucket_name = "${local.name_prefix}-assets-${random_id.suffix.hex}"
    }
  5. 5

    Drive the provider from variables

    The provider takes its region from var.aws_region, and a new Environment default tag comes from var.environment, so every resource is labelled with its environment automatically.

    infra/providers.tfwhole filehcl
    provider "aws" {
      region = var.aws_region
    
      default_tags {
        tags = {
          Project     = var.project
          Environment = var.environment
          ManagedBy   = "terraform"
          Repo        = "github.com/you/shoplite"
        }
      }
    }
  6. 6

    Name the bucket from the local

    Only the bucket block in main.tf changes; the versioning and public-access resources already reference aws_s3_bucket.assets.id, so they follow automatically.

    This rename means the plan will REPLACE the bucket. You learned in Mission 0.3 to stop and think about that. Here the bucket holds nothing important, so replacing it is fine. In production you'd migrate the data first, or keep the old name.

    infra/main.tfadd to filehcl

    Replace the existing aws_s3_bucket.assets block with this one.

    resource "aws_s3_bucket" "assets" {
      bucket = local.assets_bucket_name
    
      tags = {
        Name = "${local.name_prefix}-assets"
      }
    }
  7. 7

    Declare outputs

    Outputs are what ShopLite's deploy script will ask Terraform for, for example 'which bucket do I upload the frontend to?'.

    infra/outputs.tfwhole filehcl
    output "assets_bucket_name" {
      description = "Name of the S3 bucket holding ShopLite's static assets."
      value       = aws_s3_bucket.assets.bucket
    }
    
    output "assets_bucket_arn" {
      description = "ARN of the assets bucket, for IAM policies."
      value       = aws_s3_bucket.assets.arn
    }
  8. 8

    Re-init for the new provider, then plan and apply

    Adding a provider requires terraform init, which updates the lock file. The plan creates random_id.suffix and replaces the three bucket resources with correctly named ones.

    terminal
    $ terraform init
    terraform apply
    ── expected output ──
    - Finding hashicorp/random versions matching "~> 3.6"...
    - Installing hashicorp/random v3.7.2...
    Terraform has been successfully initialized!
     
    # random_id.suffix will be created
    + resource "random_id" "suffix" {
    + byte_length = 2
    + hex = (known after apply)
    ...
    }
     
    # aws_s3_bucket.assets must be replaced
    -/+ resource "aws_s3_bucket" "assets" {
    ~ bucket = "shoplite-assets-ak-7x2q" -> (known after apply) # forces replacement
    ~ tags = {
    ~ "Name" = "shoplite-assets" -> "shoplite-dev-assets"
    - "Owner" = "platform-team" -> null
    }
    ...
    }
    ...
    Plan: 4 to add, 0 to change, 3 to destroy.
     
    Changes to Outputs:
    + assets_bucket_arn = (known after apply)
    + assets_bucket_name = (known after apply)
    ...
    Apply complete! Resources: 4 added, 0 changed, 3 destroyed.
     
    Outputs:
     
    assets_bucket_arn = "arn:aws:s3:::shoplite-dev-assets-3f9a"
    assets_bucket_name = "shoplite-dev-assets-3f9a"
    Your suffix will differ. Run `terraform plan` again: it shows no changes, because the suffix is stored in state and reused.
  9. 9

    Read outputs from a script

    -raw prints the bare string with no quotes, ready for shell use. -json prints all outputs as JSON for tools like jq.

    terminal
    $ BUCKET=$(terraform output -raw assets_bucket_name)
    aws s3 ls "s3://$BUCKET" && echo "deploy target: $BUCKET"
    ── expected output ──
    deploy target: shoplite-dev-assets-3f9a
  10. 10

    See why each environment needs its own state

    Override the environment on the command line and plan, without applying. -var has the highest precedence, so environment becomes prod. Terraform plans to REPLACE the dev bucket with a prod one, because this folder has one state file and it now describes prod instead of dev.

    Variables alone don't give you separate environments; each environment also needs its own state. That's what Stage 6 builds.

    terminal
    $ terraform plan -var environment=prod | tail -n 1
    ── expected output ──
    Plan: 3 to add, 0 to change, 3 to destroy.

Checkpoint — you should now have

  • ✓The bucket is now named shoplite-dev-assets-<suffix>, and a second terraform plan shows no changes.
  • ✓terraform output -raw assets_bucket_name prints the bucket name.
  • ✓Resources carry an Environment = dev tag from default_tags.
  • ✓You can list the variable precedence order from lowest to highest.
  • ✓Everything is committed, including terraform.tfvars and the updated lock file.

Part 4

Break it on purpose

Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.

Break #1

Pass an environment that fails validation

Run terraform plan -var environment=production.

terminal
$ terraform plan -var environment=production
── what you'll see ──
╷
│ Error: Invalid value for variable
│
│ on variables.tf line 7:
│ 7: variable "environment" {
│ ├────────────────
│ │ var.environment is "production"
│
│ environment must be one of: dev, staging, prod.
│
│ This was checked by the validation rule at variables.tf:11,3-13.
╵

Break #2

Add a provider without re-running init

Remove the .terraform/ folder's random provider by running rm -rf .terraform/providers/registry.terraform.io/hashicorp/random, then run terraform plan. The same thing happens when a teammate pulls your commit that added the provider and plans without re-running init.

terminal
$ terraform plan
── what you'll see ──
╷
│ Error: Required plugins are not installed
│
│ The installed provider plugins are not consistent with the packages
│ selected in the dependency lock file:
│ - registry.terraform.io/hashicorp/random: there is no package for
│ registry.terraform.io/hashicorp/random 3.7.2 cached in .terraform/providers
│
│ Terraform uses external plugins to integrate with a variety of different
│ infrastructure services. To download the plugins required for this
│ configuration, run:
│ terraform init
╵

Break #3

Output a sensitive value without marking it

Add a variable db_password with sensitive = true and default = "hunter2", plus an output db_password whose value is var.db_password, but without sensitive = true on the output. Run terraform plan.

infra/outputs.tfadd to filehcl
variable "db_password" {
  type      = string
  sensitive = true
  default   = "hunter2"
}

output "db_password" {
  value = var.db_password
}
terminal
$ terraform plan
── what you'll see ──
╷
│ Error: Output refers to sensitive values
│
│ on outputs.tf line 17:
│ 17: output "db_password" {
│
│ To reduce the risk of accidentally exporting sensitive data that was
│ intended to be only internal, Terraform requires that any root module
│ output containing sensitive data be explicitly marked as sensitive, to
│ confirm your intent.
│
│ If you do intend to export this data, annotate the output value as
│ sensitive by adding the following argument:
│ sensitive = true
╵

Part 5

Interview questions from this mission

01

List the ways to set a Terraform input variable, from lowest to highest precedence.

02

When would you use a local value instead of a variable?

03

Does sensitive = true protect a secret? What does it actually do?

04

Why use random_id for a bucket suffix instead of a timestamp or uuid()?

Before you stop

Clean up

terminal
$ # Keep everything — Stage 1 moves this state into S3.
# If you're pausing for days, you can tear down safely (the bucket is empty):
terraform destroy
── expected output ──
Plan: 0 to add, 0 to change, 4 to destroy.
...
Destroy complete! Resources: 4 destroyed.
Destroying is optional here; the resources cost nothing. If you do destroy, just `terraform apply` again before Stage 1.
0/4 · 0%