Terraform – Dev, Staging and Prod Without Copy-Paste

August 9, 20265 min readUpdated 8/24/2026

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 apply

The 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 = true
terraform init -backend-config=env/prod.s3.tfbackend -reconfigure
terraform apply -var-file=env/prod.tfvars

This 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=prod

Choosing

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 together

Naming, 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.