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.
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_idfor 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.
Part 2
Your project after this mission · 8 files change
- 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
Declare typed, validated variables
Three inputs describe where and what we're deploying. The
validationblock runs at plan time;contains(...)is one of Terraform's built-in functions, andterraform consolelets 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
Set values in terraform.tfvars
Terraform loads
terraform.tfvarsautomatically. It holds no secrets, so commit it.environmenthas no default, so without this file every plan would stop and prompt for it.infra/terraform.tfvarswhole filehcl environment = "dev" - 3
Add the random provider
hashicorp/randomgenerates values like IDs and passwords and stores them in state. Every provider you use goes inrequired_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
Build names in locals
random_idwithbyte_length = 2gives four hex characters, such as3f9a. 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
Drive the provider from variables
The provider takes its region from
var.aws_region, and a newEnvironmentdefault tag comes fromvar.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
Name the bucket from the local
Only the bucket block in
main.tfchanges; the versioning and public-access resources already referenceaws_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
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
Re-init for the new provider, then plan and apply
Adding a provider requires
terraform init, which updates the lock file. The plan createsrandom_id.suffixand replaces the three bucket resources with correctly named ones.terminal$ terraform initterraform 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
Read outputs from a script
-rawprints the bare string with no quotes, ready for shell use.-jsonprints all outputs as JSON for tools likejq.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
See why each environment needs its own state
Override the environment on the command line and plan, without applying.
-varhas the highest precedence, soenvironmentbecomesprod. 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 secondterraform planshows no changes. - ✓
terraform output -raw assets_bucket_nameprints the bucket name. - ✓Resources carry an
Environment = devtag fromdefault_tags. - ✓You can list the variable precedence order from lowest to highest.
- ✓Everything is committed, including
terraform.tfvarsand 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.
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.
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.
variable "db_password" {
type = string
sensitive = true
default = "hunter2"
}
output "db_password" {
value = var.db_password
}Part 5
Interview questions from this mission
List the ways to set a Terraform input variable, from lowest to highest precedence.
When would you use a local value instead of a variable?
Does sensitive = true protect a secret? What does it actually do?
Why use random_id for a bucket suffix instead of a timestamp or uuid()?
Before you stop