Terraform workspaces vs directories for environments

When workspaces help and when separate directories are safer for dev, staging, and prod — with the tradeoffs.

Mar 11, 2025·Updated ·4 min readIntermediate·By SecOpsLog · documentation-verified
bash — the incident that gives workspaces their reputation
terraform workspace show
prod
terraform apply -var-file=dev.tfvars -auto-approve
aws_db_instance.main: Destroying... [id=shop-prod-db]
the workspace decided which state file; the var file decided what the code wanted; nothing checked that they matched

A Terraform workspace is a second state file behind the same backend configuration. terraform workspace select staging changes which state object the next command reads and writes; it changes nothing in the .tf files, the backend block, the credentials, or the variables. That is the whole feature, and it is enough for some jobs and dangerous for others. The failure above comes from the layout rather than from a bug: the thing that selects production (a workspace) and the things that describe production (a var file, an account, a role) are chosen independently by whoever types the command.

What changes when you switch

backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "network/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
# workspace_key_prefix = "env:" (the default)
}
}
# default workspace -> s3://acme-tfstate/network/terraform.tfstate
# workspace "staging" -> s3://acme-tfstate/env:/staging/network/terraform.tfstate
# workspace "prod" -> s3://acme-tfstate/env:/prod/network/terraform.tfstate

The three states live in the same bucket, are read with the same credentials, and are produced by the same code, with terraform.workspace available in HCL for anyone who wants a count = terraform.workspace == "prod" ? 3 : 1. Every one of those "same"s is a property: convenient when the environments are genuinely alike and the blast radius of confusing them is small, and a hole when one of them is production.

Workspaces vs a directory per environment

PropertyWorkspacesDirectories (live/prod/network, live/staging/network)
what selects the environmenta CLI-side switch, invisible in the codethe working directory; the path is the environment
stateone bucket, one key prefix, one set of credentialsany key, any bucket, any account per directory
credentials and accountsshared by constructiona different role or account per directory, enforced by CI
code reusethe same root module, differences via terraform.workspace and var filesthe same child modules, called with different inputs per directory
approval gatesone pipeline; a gate has to inspect the workspace namea pipeline per directory, with its own protection rules
wrong-target applyone select awayrequires being in the wrong directory with the wrong credentials

Where each one fits

Workspaces fit environments that are copies of each other by design and disposable: a per-developer sandbox in a non-production account, a preview stack per merge request, a load-test environment that exists for a day. They are a poor fit for the production/staging split, not because the mechanism fails but because it does not separate accounts, credentials or approvals, and those are what the split is for. HashiCorp's own documentation says as much: for strong isolation between environments, use separate configurations and backends. A directory per environment gives that isolation with nothing more than a path, and a wrapper such as Terragrunt removes the copy-paste that the directory layout otherwise costs.

live/prod/network/main.tf
terraform {
backend "s3" {
bucket = "acme-tfstate-prod" # a different bucket, in the production account
key = "network/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
}
}
module "network" {
source = "git::https://git.acme.dev/modules/network.git?ref=v2.1.0"
name = "prod"
cidr = "10.10.0.0/16"
}
# the only environment this directory can touch is the one its backend and its CI role point at

If you keep workspaces, make the wrong command impossible

Three guardrails turn the incident at the top into a refused command. First, tie the var file to the workspace rather than to the operator's memory: -var-file="$(terraform workspace show).tfvars" in a wrapper script or a Makefile, so prod can only ever be applied with prod.tfvars. Second, put the workspace name in the shell prompt and in every CI job log line, because the person who can see it will not apply against it by accident. Third, give production a different role or account and make the backend refuse the others: a state bucket whose policy only admits the production CI role cannot be written from a developer laptop, whatever workspace is selected.

tf (wrapper)
#!/usr/bin/env bash
set -euo pipefail
ws="$(terraform workspace show)"
[[ -f "$ws.tfvars" ]] || { echo "no var file for workspace '$ws'" >&2; exit 1; }
echo "workspace=$ws varfile=$ws.tfvars account=$(aws sts get-caller-identity --query Account --output text)"
exec terraform "$@" -var-file="$ws.tfvars"
A workspace is not a boundary
It shares code, backend, credentials and usually the account with every other workspace in the configuration. It does not replace separate accounts, IAM boundaries or approval steps, and a compliance requirement for environment separation is not met by a workspace name. Use it for what it is: a cheap second copy of state for environments that are meant to be identical and temporary.

Either layout stands on the same foundation: remote state with a lock so two applies cannot race, and versioned modules so the environments share code without sharing a root. The directory layout is where a wrapper earns its place; the workspace layout is where a wrapper script is the difference between a sandbox and an outage.

Related posts

Quick reference