Command Palette

Search for a command to run...

Hectal

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.

50 min Plan-only for prod here; applying prod costs ~$0.35/hour (Multi-AZ DB, 2 NATs) 6 steps 2 break-it drills

By the end of this mission

  • Use partial backend configuration with .tfbackend files
  • 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.

One wrapper, two fully separate environmentsdiagram
Rendering diagram…

Part 2

Your project after this mission · 8 files change

shoplite/
  • 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. 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. 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. 3

    Move values into explicit tfvars

    Delete terraform.tfvars and move its contents to envs/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. 4

    The wrapper script

    It re-initialises against the chosen environment every time (-reconfigure switches 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. 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. 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-AZ db.m7g.large, 512 CPU tasks, prod names, and deletion_protection = true. That diff is exactly what prod.tfvars asked 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 lift prevent_destroy and 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 = true
    Plan: 76 to add, 0 to change, 0 to destroy.

Checkpoint — you should now have

  • ✓backend.tf is an empty backend "s3" {}; each environment has a .s3.tfbackend and a .tfvars.
  • ✓There is no terraform.tfvars; environment has no default.
  • ✓./tf dev plan shows no changes; ./tf prod plan shows 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.

terminal
$ terraform plan -input=false
── what you'll see ──
╷
│ Error: No value for required variable
│
│ on variables.tf line 7:
│ 7: variable "environment" {
│
│ The root module input variable "environment" is not set, and has no default
│ value. Use a -var or -var-file command line argument to provide a value for
│ this variable.
╵

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.

terminal
$ terraform plan -var-file=envs/prod.tfvars
── what you'll see ──
...
# module.network.aws_subnet.private["ap-south-1a"] must be replaced
-/+ resource "aws_subnet" "private" {
~ cidr_block = "10.20.10.0/24" -> "10.30.10.0/24" # forces replacement
...
╷
│ Error: Instance cannot be destroyed
│
│ on database.tf line 28:
│ 28: resource "aws_db_instance" "main" {
│
│ Resource aws_db_instance.main has lifecycle.prevent_destroy set, but the
│ plan calls for this resource to be destroyed. To avoid this error and
│ continue with the plan, either disable lifecycle.prevent_destroy or reduce
│ the scope of the plan using the -target option.
╵

Part 5

Interview questions from this mission

01

How do you use one Terraform codebase for multiple environments with separate state?

02

Why is putting environment values in terraform.tfvars risky when you have several environments?

03

What does terraform init -reconfigure do, and why use it in a per-environment wrapper?

0/4 · 0%