Mission 6.2 · Stage 6 — Environments
Dev and Prod from the Same Code
Goal: ./tf dev plan and ./tf prod plan run the same code against separate states with separate settings — HA NAT, Multi-AZ, and bigger tasks in prod — and nobody can accidentally mix them.
By the end of this mission
- Use partial backend configuration with
.tfbackendfiles - Move environment values out of auto-loaded
terraform.tfvars - Write a wrapper that always pairs backend and variables
- Read a first-apply plan for a whole new environment
Part 1
Understand it first
Partial backend configuration
Mission 1.2 showed that backend blocks can't use variables. They CAN be left partly empty and completed at init time: backend "s3" {} in code, plus terraform init -backend-config=envs/dev.s3.tfbackend, a small key=value file with bucket, key, region, and locking settings. Each environment gets its own file, and therefore its own state key, and in 6.3 even its own bucket and account.
Nothing environment-specific in auto-loaded files
terraform.tfvars and *.auto.tfvars load automatically for EVERY run. If dev's values live there, a prod run that forgets its var-file silently gets dev's values. So environment values move to explicitly named files (envs/dev.tfvars), and required variables like environment have no default, so a run without a var-file fails immediately instead of guessing.
Promotion is a code question
With one codebase, 'promote to prod' means 'apply to prod the same commit that was applied to dev'. The wrapper guarantees the environment's inputs; Git (and CI in Stage 7) guarantees the code. Prod differs from dev only in prod.tfvars, which is short enough to review in full whenever it changes.
Part 2
Your project after this mission · 8 files change
- infra/
- envs/
- dev.s3.tfbackendnew
- dev.tfvarsnew
- prod.s3.tfbackendnew
- prod.tfvarsnew
- alb.tf
- backend.tfmodified
- checks.tf
- database.tf
- ecr.tf
- ecs.tf
- iam.tf
- locals.tf
- logs.tf
- main.tf
- outputs.tf
- probe.tf
- providers.tf
- refactors.tf
- security.tf
- storage.tf
- terraform.tfvarsdeleted
- tfnew
- uploads.tf
- variables.tfmodified
- versions.tf
Part 3
Build it, step by step
- 1
Make the backend partial
The block stays, but empty. Every setting moves into per-environment files.
infra/backend.tfwhole filehcl terraform { # Completed at init time: terraform init -backend-config=envs/<env>.s3.tfbackend backend "s3" {} } - 2
One backend file per environment
Dev keeps its existing key, so re-initialising dev moves nothing. Prod gets its own key. In 6.3 prod moves to its own account's bucket entirely.
infra/envs/dev.s3.tfbackend + prod.s3.tfbackendwhole filehcl # envs/dev.s3.tfbackend bucket = "shoplite-tfstate-c41e" key = "shoplite/dev/terraform.tfstate" region = "ap-south-1" encrypt = true use_lockfile = true # envs/prod.s3.tfbackend bucket = "shoplite-tfstate-c41e" key = "shoplite/prod/terraform.tfstate" region = "ap-south-1" encrypt = true use_lockfile = true - 3
Move values into explicit tfvars
Delete
terraform.tfvarsand move its contents toenvs/dev.tfvars. Prod differs in exactly the ways the earlier preconditions demand: Multi-AZ, a non-burstable DB, HA NAT. It also gets its own VPC range, so the two could be peered one day.infra/envs/dev.tfvars + prod.tfvarswhole filehcl # envs/dev.tfvars environment = "dev" vpc_cidr = "10.20.0.0/16" enable_nat_gateway = true single_nat_gateway = true db_instance_class = "db.t4g.micro" db_multi_az = false image_tag = "1.4.0" # envs/prod.tfvars environment = "prod" vpc_cidr = "10.30.0.0/16" enable_nat_gateway = true single_nat_gateway = false db_instance_class = "db.m7g.large" db_multi_az = true api_cpu = 512 api_memory = 1024 image_tag = "1.4.0" - 4
The wrapper script
It re-initialises against the chosen environment every time (
-reconfigureswitches backends without migrating state), then passes the matching var-file to the commands that accept one. With two arguments to type, pairing the wrong backend with the wrong variables becomes practically impossible.chmod +x tf.infra/tfwhole filebash #!/usr/bin/env bash # Usage: ./tf <dev|prod> <terraform command> [args...] set -euo pipefail env="${1:?usage: ./tf <env> <command> [args]}"; shift cmd="${1:?missing terraform command}" cd "$(dirname "$0")" [[ -f "envs/${env}.tfvars" && -f "envs/${env}.s3.tfbackend" ]] || { echo "unknown environment: ${env}" >&2; exit 1; } terraform init -input=false -reconfigure -backend-config="envs/${env}.s3.tfbackend" >/dev/null case "$cmd" in plan|apply|destroy|console|import|refresh) terraform "$@" -var-file="envs/${env}.tfvars" ;; *) terraform "$@" ;; esac - 5
Confirm dev is untouched
Same key, same values, so no changes. This proves the restructure is purely organisational.
terminal$ ./tf dev plan── expected output ──No changes. Your infrastructure matches the configuration. - 6
Plan prod for the first time
An empty state, so everything is
+ create. Read the parts that DIFFER from dev: two NAT gateways, a Multi-AZdb.m7g.large, 512 CPU tasks, prod names, anddeletion_protection = true. That diff is exactly whatprod.tfvarsasked for. Applying is optional here. It works and it's a good test, but it costs real money, so destroy it afterwards (you'll need to liftprevent_destroyand deletion protection first, which is by design).terminal$ ./tf prod plan | grep -E '(multi_az|instance_class|deletion_protection|nat_gateway.main|Plan:)'── expected output ──# module.network.aws_nat_gateway.main["ap-south-1a"] will be created# module.network.aws_nat_gateway.main["ap-south-1b"] will be created+ deletion_protection = true+ instance_class = "db.m7g.large"+ multi_az = truePlan: 76 to add, 0 to change, 0 to destroy.
Checkpoint — you should now have
- ✓
backend.tfis an emptybackend "s3" {}; each environment has a.s3.tfbackendand a.tfvars. - ✓There is no
terraform.tfvars;environmenthas no default. - ✓
./tf dev planshows no changes;./tf prod planshows a full, prod-sized environment. - ✓You can point to every dev/prod difference in
prod.tfvars.
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
Run plain terraform in CI
Run terraform plan -input=false directly (not through ./tf), as a CI job without the wrapper would.
Break #2
Pair prod variables with dev state
Bypass the wrapper: terraform init -reconfigure -backend-config=envs/dev.s3.tfbackend, then terraform plan -var-file=envs/prod.tfvars.
Part 5
Interview questions from this mission
How do you use one Terraform codebase for multiple environments with separate state?
Why is putting environment values in terraform.tfvars risky when you have several environments?
What does terraform init -reconfigure do, and why use it in a per-environment wrapper?