You have a stack that works. Now you need it three times — dev, staging, prod — differing in instance sizes, counts, and not much else. There are three ways to do this and one of them is a trap for production.
Option 1: workspaces
Terraform's built-in answer. One directory, multiple state files.
terraform workspace new staging
terraform workspace new prod
terraform workspace list
default
staging
* prod
terraform workspace select staging
terraform applyThe state key gets a prefix per workspace, so prod and staging hold
separate state from the same code. Inside the configuration:
locals {
instance_class = terraform.workspace == "prod" ? "db.r6g.large" : "db.t4g.micro"
desired_count = terraform.workspace == "prod" ? 3 : 1
name_prefix = "pizza-${terraform.workspace}"
}⚠️ Why workspaces are wrong for production
They work. The problem is not correctness, it is that the environment is ambient state — a property of your shell, not of anything you can see, review or version.
Which workspace are you in right now? Nothing in the code says. Nothing in a pull
request says. You find out by running a command, and if you forget, terraform apply
applies to whichever one you last selected. The failure mode is running a staging change against
production and only noticing at the confirmation prompt — if then.
The configuration is identical, so it cannot diverge. Which sounds like a feature
until prod needs a read replica that staging does not, and you start writing
count = terraform.workspace == "prod" ? 1 : 0 through the codebase. A few of those and
the configuration is a pile of conditionals that no single environment reads cleanly.
One backend. Every workspace lives in the same bucket, so anyone who can plan staging can read prod's state — which contains prod's secrets. Separate AWS accounts per environment is a common and good practice, and workspaces cannot express it.
The blast radius is shared. One directory, one set of files. There is no
structural barrier between a careless destroy and production.
Workspaces are genuinely good for short-lived, throwaway copies: a per-developer sandbox, an ephemeral environment per pull request, a scratch stack you will destroy this afternoon. Same code, different name, no divergence wanted, low stakes.
Option 2: a directory per environment
infra/
modules/
network/
database/
service/
environments/
dev/
main.tf calls the modules with dev's values
backend.tf its OWN backend, its OWN state
terraform.tfvars
staging/
prod/# environments/prod/main.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-329580012644"
key = "pizza/prod/terraform.tfstate"
region = "us-west-2"
encrypt = true
use_lockfile = true
}
}
module "network" {
source = "../../modules/network"
name_prefix = "pizza-prod"
vpc_cidr = "10.30.0.0/16"
}
module "database" {
source = "../../modules/database"
name_prefix = "pizza-prod"
instance_class = "db.r6g.large"
multi_az = true
}Everything the workspace approach makes implicit is now explicit and reviewable:
You can see which environment you are in — it is the directory you are standing in, and it is in the path of every command you run.
Environments can differ without conditionals. Prod's main.tf says
multi_az = true; dev's does not mention it. Each file reads as a description of one
environment.
Separate backends, separate permissions, separate accounts if you want them.
A diff to environments/prod/ is visibly a production change in a pull
request, and can require a different reviewer.
The cost is duplication. Three main.tf files that are 80% identical, and a change to
the module interface touches all three. In practice that duplication is small — the modules hold the
logic, and the environment files are a list of values — and it is duplication that you can see,
which is the good kind.
This is the layout to reach for. It is what the stack in this track would become on the way to production.
Option 3: one directory, tfvars and partial backends
The middle path. One configuration, per-environment values and backend config passed in:
infra/
main.tf
variables.tf
backend.tf contains only: terraform { backend "s3" {} }
env/
prod.tfvars
prod.s3.tfbackend
staging.tfvars
staging.s3.tfbackend# env/prod.s3.tfbackend
bucket = "my-terraform-state-329580012644"
key = "pizza/prod/terraform.tfstate"
region = "us-west-2"
encrypt = true
use_lockfile = trueterraform init -backend-config=env/prod.s3.tfbackend -reconfigure
terraform apply -var-file=env/prod.tfvarsThis exists because the backend block cannot take variables — it is read before Terraform evaluates anything, so partial configuration is the only way to parameterise it.
No duplication, and environments still cannot diverge structurally. But it brings back the worst
property of workspaces: the environment is in your command line. Forget
-var-file and you apply defaults to prod's state. Forget -reconfigure after
switching and you apply prod's values to staging's state.
If you use this, wrap it — a Makefile or a script that takes the environment name once and derives both flags, so the two can never disagree:
make plan ENV=prodChoosing
workspaces ephemeral copies: per-developer, per-PR, throwaway
directory per env real environments, especially production <-- default
tfvars + backend real environments, when the duplication genuinely hurts,
and only behind a wrapper that sets both flags togetherNaming, whichever you pick
Every resource name and every tag must carry the environment. It is what makes the console readable, the bill attributable, and a teardown provably complete:
locals {
name_prefix = "pizza-${var.environment}"
tags = {
Environment = var.environment
Project = "terraform-tutorial"
ManagedBy = "terraform"
}
}⚠️ Globally-unique names — S3 buckets especially — need the account id too, since
pizza-prod-uploads is taken across every AWS account on earth. Use
data.aws_caller_identity.current.account_id from lesson 7 rather than hard-coding it.
Protecting production
Whichever layout you choose, two guards are worth having:
resource "aws_db_instance" "this" {
identifier = "pizza-prod-mysql"
engine = "mysql"
instance_class = "db.r6g.large"
# In prod, both of these are true. terraform destroy then ERRORS rather
# than deleting the database, and removing the block is a reviewable commit.
deletion_protection = true
lifecycle {
prevent_destroy = true
}
}And in CI, prod's apply should require an approval that dev's does not — lesson 14 wires that up with a GitHub environment.
Next
The language is done. Lesson 12 starts the real deployment: a VPC, subnets, security groups and an RDS instance for the pizza API, with the database password kept out of both the code and the state file.