Terraform workspaces look like the built-in answer to “I have dev, stage and prod”. They are a trap, and the trap is that one wrong terraform apply hits the wrong environment with nothing standing in the way.
I run every long-lived environment as its own directory, with its own state and its own tfvars, and I accept the duplication that comes with it. This is a deliberate choice against the mechanism Terraform ships for exactly this purpose. Here is the reasoning, because the duplication is real and I do not want you copying the pattern without understanding what it buys.
What a workspace actually is
A Terraform workspace is not an environment. It is a named slot for a state file inside one backend, driving one set of configuration. When you run terraform workspace new prod, you do not get a new backend, new credentials, or a new code path. You get a second state file living beside the first, in the same bucket, under the same backend block. On the S3 backend, non-default workspace state lands at env:/<workspace>/<key> — same bucket, same object key prefix, differentiated by a path segment.
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
That single shared backend is the whole problem. The thing that decides whether you are about to modify dev or prod is the invisible, stateful selection held by terraform workspace select. It is not in the code you are looking at. It is not in the plan command you type. It is a piece of session state you have to remember to check.
The failure mode I care about
Picture the standard workspace layout. One directory. One main.tf. Environments distinguished by terraform.workspace and a lookup map:
locals {
instance_size = {
dev = "t3.small"
stage = "t3.medium"
prod = "m6i.xlarge"
}
size = local.instance_size[terraform.workspace]
}
resource "aws_instance" "api" {
instance_type = local.size
# ... shared for every environment
}
Now walk through a normal Tuesday. You were in prod an hour ago checking something. You come back, edit the config, run terraform apply, and approve the plan because the diff looks like the small change you intended. You were still in prod. Nothing in your terminal, your code, or your command line told you otherwise. The only guard was your memory, and memory is not a control.
The second, quieter problem is drift. Because every environment is the same code path selected at runtime, per-environment drift becomes hard to see. There is no artefact that says “this is prod”. You cannot diff prod’s configuration against stage’s by reading two files, because there is only one file and the difference is smeared across lookup maps and conditionals. When a regulator or an auditor asks you to show what is deployed in production and how it differs from staging, “it depends which workspace was selected” is not an answer you want to give.
The layout I use instead
A directory per environment. Each one has its own backend configuration, its own state, its own variables, and its own credentials. The shared logic lives in modules that every environment calls.
.
├── modules/
│ └── api-service/ # the reusable logic, defined once
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── environments/
├── dev/
│ ├── main.tf # calls ../../modules/api-service
│ ├── backend.tf # dev bucket / dev key
│ └── terraform.tfvars
├── stage/
│ ├── main.tf
│ ├── backend.tf
│ └── terraform.tfvars
└── prod/
├── main.tf
├── backend.tf # SEPARATE bucket, separate key
└── terraform.tfvars
The prod directory carries its own backend, pinned to a separate bucket and ideally a separate account:
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "api-service/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "acme-tflock-prod"
encrypt = true
}
}
# environments/prod/main.tf
module "api" {
source = "../../modules/api-service"
instance_type = var.instance_type # from prod's own tfvars
}
Now the environment you are operating on is the directory you are standing in. To touch prod you have to cd environments/prod — or pass terraform -chdir=environments/prod apply — which is a visible, deliberate act that appears in your shell history and your CI job name. Prod has its own state, its own lock table, and its own credentials, so a mistake made against dev physically cannot reach into prod’s state. The blast radius is bounded by the filesystem, not by your recall.
Keeping the duplication honest
The obvious objection is DRY: three directories means three copies of the wiring. Managed carelessly, that is how prod quietly diverges from stage in ways nobody intended. The discipline that keeps it sane:
- All real logic lives in modules. The per-environment directory should be thin — a module call, a backend block, and a
tfvarsfile. If resources are being defined directly in an environment directory, you have made the duplication expensive. - The only thing that differs is variables. Sizes, counts, feature flags and account IDs go in
terraform.tfvars. If two environments differ in anything else, that difference is now explicit and reviewable in a pull request. - Pin the module version. Promotion from stage to prod becomes a one-line change to a module source ref, reviewed like any other change. This is where a working pre-commit and CI baseline earns its keep —
terraform fmt,validateand a plan on every directory catch drift before it ships.
Where workspaces genuinely fit
I am not against workspaces — I am against using them as your environment boundary. They are the right tool when the environments are ephemeral and identical: a per-pull-request preview stack, a short-lived test environment spun up and torn down in CI, a fan-out where you want twenty structurally identical copies keyed by name. In those cases the whole point is that the copies are interchangeable and disposable, the shared backend is a feature rather than a hazard, and nobody is standing at a terminal about to hand-apply to one of them by accident.
The distinction is whether an environment is a thing you protect or a thing you throw away. Prod is a thing you protect. It deserves a boundary you can see, a state file only it can reach, and a promotion path you can put in front of an auditor. If you are already measuring your change-failure rate, this is one of the cheapest ways to move it: make the wrong environment hard to touch by accident, rather than trusting everyone to remember which workspace they left selected.
Duplication you can review beats cleverness you have to remember. For the environment that pages you at 3am, that trade is not close.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.