The Wrong-Workspace Apply: Why Shared Configuration Makes Mistakes Quiet
Here is the scenario interviewers like, because it has happened to everyone: you are updating staging. Your shell is in the right directory, the diff looks like the two-instance staging stack, you run terraform apply, and it reaches production — because an hour ago you ran terraform workspace select production to inspect something and never switched back.
Nothing about the plan shouted. The diff for one workspace is structurally identical to the diff for another — same resource addresses, same module tree, same lines — because both are generated from the same configuration. The workspace name lives only in .terraform/environment, in TF_WORKSPACE if you exported it, and in your memory. Three concrete ways this class of mistake happens:
- Stale selection. The workspace is per directory, not per shell, so switching in one terminal switches it everywhere, including CI jobs that reuse a checkout.
- Typo'd workspace names.
terraform workspace new prodctionhappily creates a fresh, empty workspace. The next plan proposes to create the whole estate; applying it in a shared account fails halfway and leaves orphans. - Forgotten
TF_WORKSPACE. The CLI docs explicitly warn that in a local shell "it can be easy to forget the variable is set and apply changes to the wrong state."
Defence in depth, in the order that actually works:
set -euo pipefail
CURRENT="$(terraform workspace show)"
if [ "$CURRENT" != "$EXPECTED" ]; then
echo "wrong workspace: expected $EXPECTED, got $CURRENT" >&2
exit 1
fi
terraform plan -input=false -var-file="$EXPECTED.tfvars" -out=tfplan
Run that assertion in every pipeline before init's results are used, and echo terraform workspace show into the job log so the record of every run names the workspace. In configuration, add a precondition guard that fails the plan when terraform.workspace disagrees with the environment variable the pipeline injected — a failing precondition aborts the run, whereas a check block only warns. Tag every resource with Environment = terraform.workspace so the cloud console identifies each object after the fact.
Two mitigations are weaker than they look. The plan file alone does not identify its workspace — the state key prefix does, in the backend, not in the diff. And -target does not make a run safer; it changes which resources are considered, not which state is written.
5. Destroy from the wrong workspace. Contained — it can only destroy what that workspace tracks — but it is the same mistake wearing a louder costume, and the precondition guard catches it for the same reason.
When an apply lands in the wrong tier, recovery is mechanical, and knowing the sequence keeps it calm:
- Stop. Do not try to undo it with a second apply from the wrong directory.
- Select the correct workspace and run
terraform plan: the inverse diff shows exactly what the wrong apply changed, expressed as the changes needed to return. - Apply that diff to restore the tier, then run the intended change from the right workspace.
- Pull a state backup first —
terraform state pull > backup.json— and check the backend's version history (S3 object versioning) if entries were deleted.
Tagging turns detection from guesswork into a query. With Environment = terraform.workspace on every resource, aws ec2 describe-instances --filters Name=tag:Environment,Values=production tells you in seconds whether an object belongs to the tier you believe it does — and a Environment=staging tag sitting in the production account is the alarm you want to see early.
The residual risk is structural: everyone who can run Terraform against this backend can select every workspace, including production, with the same credentials. Workspace boundaries are labels on state, not permissions. When that stops being acceptable — separate accounts, restricted state access, prod-only reviewers — you have outgrown workspaces for permanent tiers, and the decision framework in the next chapter, Directory Layouts per Environment, is how you formalise the split.
When Workspaces Are the Wrong Tool: Choosing a Directory per Environment
Workspaces solve a specific problem: many states for one identical configuration. The tool is wrong the moment your environments are not identical, or the moment the states must be governed differently. Use this decision table rather than team habit:
| Your situation | Reach for |
|---|---|
| Ephemeral preview environments, one per pull request | Workspaces (terraform workspace new pr-482) |
| Environments differ only in size and labels | Workspaces |
| Topology differs: prod has extra resources or a different module set | Directories |
| Separate AWS accounts / state buckets with restricted IAM | Directories |
| Prod must run an older module or provider version | Directories |
| Different people or approval gates own prod | Directories |
| A merge to dev should be unable to alter prod's config at all | Directories |
Three of those rows are structural, not stylistic.
Before deciding, ask five questions of the estate:
- Can a developer holding staging credentials read production state today? If yes, directories over separate buckets are not optional.
- Does production own resources the other tiers do not? If yes, conditionals will accumulate until the root is a matrix of branches.
- Must production run an older provider or module release for stability or compliance? If yes, you need per-tier pins.
- Should a merge touching only dev be unable to change production's files? Directories give you that structurally; workspaces cannot.
- How many environments exist and how long do they live? A dozen pull-request previews dying within 48 hours belong in workspaces; three permanent tiers do not.
State credentials. One backend "s3" block means one credential path for every workspace. With directories, envs/prod/backend.tf points at a bucket that only the production deploy role can touch — staging developers lose s3:GetObject on production state outright. No workspace setting can produce that separation.
Merge blast radius. Under workspaces, every merge lands on the configuration that all tiers read; whether it applies is per workspace, but the code path is shared. With directories, a change confined to envs/dev/** physically cannot modify what production's root contains, and CI can skip production entirely.
Version lag. Each root pins its own module version and its own .terraform.lock.hcl, so staging can adopt module ~> 2.4 while production waits a week on ~> 2.1. Under a shared configuration there is only one version constraint to write.
The classic objection to directories — "now we copy the same 80 lines three times" — has a real answer, and it is tooling rather than discipline. Terragrunt keeps one definition and generates the glue per directory:
# envs/staging/vpc/terragrunt.hcl
include "root" { path = find_in_parent_folders() }
inputs = merge(
read_terragrunt_config(find_in_parent_folders("common.hcl")).inputs,
{ node_count = 3 }
)
The root terragrunt.hcl generates the backend and provider blocks and reads shared inputs once, so each environment directory is ten lines while state stays fully separate. Duplicated logic is the smell; duplicated wiring is cheap. HCP Terraform Stacks attacks the same problem from the platform side: one stack definition, one stack instance per environment, each instance holding its own state.
Teams often end up running both mechanisms, and that is a coherent answer rather than indecision: directories for the permanent tiers — dev, staging, production — plus workspaces inside the dev root for per-pull-request previews, where clone-and-throwaway is exactly the behaviour you want (terraform workspace new pr-482, apply, terraform workspace delete). The two stop competing once you see they answer different questions: directories separate governance, workspaces separate copies.
So the pragmatic rule most teams land on: workspaces for disposable environments that should be clones, directories for permanent tiers that carry different blast radius, credentials, and owners. The next chapter builds the directory model concretely — see Directory Layouts per Environment.
Quiz
1. One configuration must expose a WAF only in production. What does the workspace model require?
2. terraform.tfvars in your working directory sets account_id = '111122223333'. You switch to the production workspace and apply. Which account is used?
3. You want staging on module version ~> 2.4 while production stays on ~> 2.1. What is the consequence of using workspaces?
4. Which argument most strongly favours directory-per-environment for a multi-account estate?
Flashcards
Question
Which files and settings do all workspaces of a configuration share?
Click to reveal answer
Answer
Every .tf file, .terraform.lock.hcl, the backend block and its credentials, provider constraints, module versions, and all variable definition files such as terraform.tfvars. Only the state — and inputs passed at runtime or branches on terraform.workspace — differ.
Question
How do you supply per-environment values when terraform.tfvars is shared?
Click to reveal answer
Answer
Pass them explicitly: terraform plan -var-file=staging.tfvars, or TF_VAR_<name> environment variables set by the pipeline. One tfvars file per environment, named after it, selected by the caller — never a shared tfvars that hard-codes one account.
Question
Why is a typo in terraform workspace new dangerous, and what guard catches it?
Click to reveal answer
Answer
The misspelled workspace is created with empty state, so the next plan proposes to create the entire estate and an apply half-succeeds in a shared account. Guard by asserting the name before planning: test "$(terraform workspace show)" = "$EXPECTED" || exit 1.
Question
When are workspaces the right isolation tool?
Click to reveal answer
Answer
For ephemeral or preview environments — one per pull request — and for tiers that should be clones differing only in size and tags, with one team and one backend. They are a poor fit when topology, credentials, module versions, or owners differ between tiers.
Question
How does a shared configuration widen the blast radius of a bad change?
Click to reveal answer
Answer
Every merge lands on the files all tiers read, so a broken module bump is present everywhere at once — whether it applies is per workspace, but the code path is shared. With directories, a change confined to envs/dev/** cannot alter what the production root contains.
Revision Notes
Key Takeaways
- 1. Workspaces trade flexibility for uniformity: one configuration, one lock file, one set of module versions, and variable files that are not workspace-aware
- 2. Per-environment inputs must be passed explicitly with -var-file or TF_VAR_, because a shared terraform.tfvars silently applies to every workspace
- 3. Wrong-workspace applies are quiet because the diff is structurally identical in every workspace — assert terraform workspace show and add a precondition guard
- 4. Directory-per-environment earns its cost through state IAM separation, deliberate module version lag, and merges that cannot touch another tier's configuration
- 5. The pragmatic split most teams land on: workspaces for disposable clone environments, directories for permanent tiers with different blast radius and owners
Interview Tips
- • 'Workspaces or separate directories?' — answer with criteria, not preference: clones differing only in size → workspaces; different accounts, topology, module versions, or owners → directories
- • 'What can I vary between workspaces?' — only inputs and terraform.workspace branches; config, providers, modules, lock file, and tfvars are shared, so parameterising module versions weakens your pin
- • 'How would you stop someone applying to the wrong workspace?' — assert terraform workspace show in the script, add a lifecycle precondition on an expected_workspace variable, tag resources with terraform.workspace, and keep prod credentials in CI only
- • 'Why do preview environments fit workspaces well?' — they are disposable clones: cheap to create with terraform workspace new, cheap to delete, and nobody needs separate credentials or version lag for a PR environment
Cheat Sheet
Workspace Design Tradeoffs
What workspaces share (everything except state): config files, .terraform.lock.hcl, backend block, state credentials, provider credentials, module versions, terraform.tfvars (variable files are not workspace-aware — pass -var-file explicitly).
Decision table:
| Choose workspaces when… | Choose directories when… |
|---|---|
Preview/ephemeral envs (pr-482) |
Topology differs per tier |
| Differ only in size and tags | Separate accounts / state buckets |
| One team, one backend | Prod must lag module versions |
| Clone-and-throwaway | Different owners, review gates, IAM |
Failure modes of shared config:
- Wrong-workspace apply — the diff looks identical in every workspace; assert
terraform workspace showfirst - Typo in
terraform workspace new→ empty state → plan wants the whole estate TF_WORKSPACEleft exported in a shell- Shared backend credentials = every workspace readable by anyone who can run Terraform
Hardening:
test "$(terraform workspace show)" = "$EXPECTED" || exit 1
terraform plan -input=false -var-file="$EXPECTED.tfvars" -out=tfplan
lifecycle {
precondition {
condition = terraform.workspace == var.expected_workspace
error_message = "Wrong workspace for this run."
}
}
tags = { Environment = terraform.workspace }
Directory-per-env without copy-paste: Terragrunt include "root" { path = find_in_parent_folders() } generates backend/provider glue; one root config, N thin environment directories. HCP Terraform Stacks: one definition, one instance per environment.