Every Terraform team eventually faces the same question: how do we manage dev, staging, and prod from one codebase? Terraform offers two answers. Workspaces let you reuse one configuration with separate state per workspace. Directory-per-environment gives each environment its own directory, its own backend config, and its own variables. Both work. Both have sharp edges. Picking the wrong one for your team size creates the kind of mess that takes a quarter to unwind.
This post explains how each approach actually works, where each one breaks down, and the hybrid pattern most growing teams land on.
How workspaces actually work
A workspace is a named container for state inside a single backend. One configuration, multiple state files. The commands are simple:
terraform workspace new staging # create and switch to staging terraform workspace list # show all workspaces, * marks current terraform workspace select prod # switch to prod terraform workspace show # print the current workspace name With an S3 backend, each workspace gets its own state path. You control the layout with workspace_key_prefix:
terraform { backend "s3" { bucket = "myorg-terraform-state" key = "app/terraform.tfstate" workspace_key_prefix = "workspaces" region = "us-east-1" dynamodb_table = "terraform-state-locks" encrypt = true } } With this config, the staging workspace stores state at workspaces/staging/app/terraform.tfstate. The default workspace uses the key as-is: app/terraform.tfstate. Many teams delete or ignore the default workspace to avoid confusion, since its state path looks different from all the others.
Inside your configuration, terraform.workspace exposes the current workspace name, so resources can vary by environment:
resource "aws_s3_bucket" "app" { bucket = "myorg-app-${terraform.workspace}" } resource "aws_instance" "web" { ami = "ami-0c55b159cbfafe1f0" instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro" } That conditional is where workspaces start showing their limits, as we will see.
Where workspaces shine
Workspaces are excellent for environments that are temporary, numerous, or structurally identical.
Ephemeral environments. Spinning up a short-lived environment for a demo or a load test is one command: terraform workspace new loadtest-2026-11. When you are done, terraform workspace delete after destroying. No new directories, no new backend configs.
Feature branch environments. A developer working on a networking change creates a workspace named after the branch, tests the change in isolation, and tears it down on merge. The state is separate, so experiments cannot corrupt shared environments.
Personal sandboxes. Each engineer gets a workspace for learning and experimentation. Cheap, isolated, disposable.
The common thread: workspaces are great when environments come and go, when they are structurally identical, and when the blast radius of a mistake is small.
Where workspaces break down
Production is none of those things. Here is where workspaces hurt.
The wrong-workspace disaster. Every workspace shares one backend configuration and one set of code. The only thing standing between you and applying the dev configuration to prod state is the output of terraform workspace show. Forget to check, and you apply against the wrong state file. The blast radius is the entire environment. This is the classic workspaces failure mode, and it happens to careful engineers, because the safety mechanism is human attention, and human attention fails.
Variable management pain. Workspaces share one backend block, which means one backend configuration for all environments. Per-environment differences have to come from somewhere else: tfvars files you must remember to pass, or conditionals on terraform.workspace scattered through the code. A three-environment setup accumulates ternaries like this:
instance_type = ( terraform.workspace == "prod" ? "t3.large" : terraform.workspace == "staging" ? "t3.medium" : "t3.micro" ) This works for three environments. It does not survive ten. And every conditional is a place where someone adds a new workspace name and forgets to update the logic.
Backend differences are impossible. Some teams need different backends per environment: separate AWS accounts, separate state buckets for compliance, different regions. Workspaces cannot do this. One backend block, one bucket, one region. If compliance requires prod state in a separate account from dev state, workspaces are disqualified on arrival.
CI/CD ambiguity. A pipeline applying workspaces must select the right workspace before every operation. That is one more step that can go wrong, and unlike a human, a pipeline will not notice it selected wrong. You end up writing wrapper scripts to make workspace selection safe, which is a sign the abstraction is fighting you.
The directory-per-environment pattern
Each environment gets its own directory with its own backend configuration and its own variables:
envs/ dev/ main.tf variables.tf backend.hcl terraform.tfvars staging/ main.tf variables.tf backend.hcl terraform.tfvars prod/ main.tf variables.tf backend.hcl terraform.tfvars modules/ app/ main.tf variables.tf outputs.tf The shared logic lives in modules/app. Each environment directory is thin: it calls the module with environment-specific inputs.
# envs/prod/main.tf module "app" { source = "../../modules/app" environment = "prod" instance_type = "t3.large" replica_count = 3 } # envs/prod/backend.hcl bucket = "myorg-terraform-state-prod" key = "app/terraform.tfstate" region = "us-east-1" dynamodb_table = "terraform-state-locks-prod" encrypt = true # envs/dev/backend.hcl bucket = "myorg-terraform-state-dev" key = "app/terraform.tfstate" region = "us-east-1" dynamodb_table = "terraform-state-locks-dev" encrypt = true Initialize with the backend config for that directory:
cd envs/prod terraform init -backend-config=backend.hcl Notice what this buys you. Prod and dev can use entirely different state buckets, different lock tables, different accounts, different regions. There is no workspace to forget to select, because the directory you are standing in is the environment. terraform apply in envs/prod can only ever touch prod state. The safety mechanism is the filesystem, not human attention.
Variables live in terraform.tfvars per directory, loaded automatically. No ternaries on workspace names. No remembering to pass -var-file. Each environment's configuration is explicit and reviewable in a pull request.
Head-to-head comparison
| Concern | Workspaces | Directory-per-environment |
|---|---|---|
| Safety | Low. One forgotten workspace select applies to the wrong environment. | High. The directory is the environment. Wrong-directory applies are caught by code review of the path. |
| Clarity | Medium. Logic branches on terraform.workspace hide environment differences in conditionals. | High. Each environment's inputs are explicit in its own files. |
| CI/CD fit | Awkward. Pipeline must select workspace before every run. Wrapper scripts common. | Natural. Pipeline checks out and runs in the environment directory. The path is the selector. |
| Team scaling | Poor past a few environments. Conditionals multiply, backend is shared. | Good. New environment is a new directory. Teams own their directories. |
| Backend isolation | Impossible. One backend block for all workspaces. | Full. Different buckets, accounts, regions per environment. |
| Ephemeral envs | Excellent. One command to create, one to delete. | Heavy. New directory plus backend config for something lasting a day. |
| Variable management | Painful. Ternaries or external var files you must remember. | Clean. terraform.tfvars per directory, auto-loaded. |
| Compliance separation | Fails. Cannot separate state storage per environment. | Passes. Prod state can live in its own account and region. |
The recommended hybrid
Most growing teams land here: directories for real environments, workspaces for temporary ones.
envs/ dev/ staging/ prod/ Dev, staging, and prod each get a directory with isolated backends. These are the environments where a mistake costs money or customers, so they get the safest pattern.
For ephemeral needs, workspaces inside the dev directory:
cd envs/dev terraform workspace new feature-x # test, then destroy and delete the workspace terraform workspace select default terraform workspace delete feature-x Dev is the right home for experimental workspaces because the blast radius is contained and the backend is already the dev one. Nobody creates experimental workspaces in prod. That rule is worth writing down.
This hybrid gives you the safety of directories where it matters and the convenience of workspaces where speed matters. It also matches how teams actually grow: you start with workspaces everywhere, you get burned once or twice, and you graduate the important environments to directories.
What about Terragrunt?
If the directory pattern looks repetitive, you are noticing the real tradeoff. Each environment directory repeats similar main.tf calls and backend configs. Terragrunt exists to remove that duplication: one root configuration defines the backend and provider setup once, and each environment directory holds only its inputs.
# terragrunt.hcl at the repo root remote_state { backend = "s3" config = { bucket = "myorg-terraform-state" key = "${path_relative_to_include()}/terraform.tfstate" region = "us-east-1" dynamodb_table = "terraform-state-locks" encrypt = true } } # envs/prod/terragrunt.hcl include "root" { path = find_in_parent_folders() } inputs = { environment = "prod" instance_type = "t3.large" replica_count = 3 } Terragrunt is a legitimate answer to the duplication problem, and teams running dozens of environments often adopt it. The honest cost: it is another tool to learn, another layer between you and Terraform when something breaks, and debugging a Terragrunt problem requires understanding both tools. For three to five environments, plain directories with small amounts of repetition are simpler and easier for new team members to follow. Consider Terragrunt when the repetition genuinely hurts, not before.
Migrating from workspaces to directories
If you are on workspaces today and want to move, the migration is mechanical. Do it one environment at a time, starting with the least critical.
# 1. From the old workspace-based project root, export the state terraform workspace select staging terraform state pull > /tmp/staging-state.json # 2. Set up the new directory with its own backend config mkdir -p envs/staging # (write main.tf calling the shared module, backend.hcl, terraform.tfvars) # 3. Initialize and push the exported state into the new backend cd envs/staging terraform init -backend-config=backend.hcl terraform state push /tmp/staging-state.json # 4. Verify: this must show no changes terraform plan Step 4 is the whole migration. If the plan is clean, the new directory manages exactly what the workspace managed, and you can delete the old workspace. If the plan shows changes, stop: something differs between the old configuration and the new module call. Fix it before proceeding.
Keep the old workspace project untouched until every environment is migrated and verified. Do not delete the old backend until the new one has survived at least one full apply cycle. Migrations done in a hurry are how you end up managing the same infrastructure from two places.
One more note: terraform state push overwrites whatever is in the target backend. Since you just initialized a fresh backend, it is empty, which is what you want. Never push into a backend that already has state for the same resources.
Pre-flight checklist
- [ ] Real environments (dev, staging, prod) use directory-per-environment with isolated backends
- [ ] Each environment directory has its own
backend.hclandterraform.tfvars - [ ] Shared logic lives in versioned modules, environment directories are thin callers
- [ ] No
terraform.workspaceconditionals in code managing real environments - [ ] Workspaces restricted to ephemeral use inside dev, with a written rule against prod workspaces
- [ ] CI/CD pipelines run in the environment directory, no workspace selection step
- [ ] Migration verified with clean
terraform planbefore old workspaces are deleted - [ ] Old backend retained until the new layout survives a full apply cycle
Closing
Workspaces and directories are both fine tools used in the right place. Workspaces optimize for speed of creating environments. Directories optimize for safety of operating them. Production deserves safety. Experiments deserve speed. Give each what it needs and stop fighting the tool.
Next week: secrets in Terraform. State files contain secrets, variables contain secrets, and every team eventually learns this the embarrassing way. Vault integration, dynamic secrets, and keeping sensitive values out of state, logs, and version control.

