Workspaces & environments

dev, staging, prod from one codebase.

Intermediate12 min · lesson 11 of 23

A test kitchen and the dinner service line run the same recipes. One is where you burn things on purpose. The other has two hundred people waiting. Dev, staging and production work the same way: identical code, wildly different consequences. Your job is to run all three from one Terraform codebase without copy-paste, and to know exactly where the wall between them sits. Terraform hands you two ways to do that. On the documentation page they look like alternatives. In an incident they behave nothing alike.

What a Workspace Actually Is

Start with state. Terraform keeps a state file, which is really a ledger: it maps each resource in your code to the real thing that code created, so the block named aws_instance.web knows it means i-04f2c9a1d77b3e5c6. A workspace is a second ledger kept under the same configuration. Same .tf files, same backend (the shared place the ledger lives, almost always a cloud storage bucket), same provider credentials (the keys Terraform uses to call the cloud API, or Application Programming Interface, the machine-to-machine control surface for your account), different ledger. Think of a hotel master keycard. One card opens every room. Which door it opens depends on a number you typed a while ago and have long since stopped thinking about.

terminal
# one configuration, several ledgers (prod already exists here)
terraform workspace new staging
terraform workspace list
output
Created and switched to workspace "staging"!
You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.
default
prod
* staging

Creating a workspace drops you into it immediately, and its ledger starts empty. Hold on to that fact. Plan in the wrong workspace and Terraform never suspects a thing. It compares your code against a ledger that knows nothing, decides the whole environment is missing, and politely offers to build it. Point that at an account where the environment already exists and you get duplicate resources, name collisions, and an argument with a load balancer that already owns the DNS name (Domain Name System, the readable address your users actually type). The tell is a plan that says 63 to add when you expected 1 to change.

So where does the second ledger live? With the S3 backend (Amazon Simple Storage Service, the object store that is the most common home for Terraform state), Terraform slots the workspace name into the object key under a prefix that defaults to env:. The default workspace keeps the bare key you configured, which is why one line in this listing looks unlike the other two.

terminal
# every workspace's state, side by side in one bucket
aws s3 ls s3://acme-tfstate --recursive
output
2026-07-14 09:12:44 18422 env:/prod/network/terraform.tfstate
2026-07-14 09:10:02 17980 env:/staging/network/terraform.tfstate
2026-07-02 16:41:19 9110 network/terraform.tfstate

Read that listing as an access-control question, not a filing question. Three environments, one bucket, one bucket policy (the rules saying which identities may read and write what), and one set of credentials that can reach all three. Terraform also needs list permission on that prefix for workspace list to work at all, so the identity your dev pipeline holds can usually enumerate and read production's ledger. State stores whatever the providers handed back, in plaintext: database passwords, generated private keys, session tokens. Where you put workspaces decides who can read your secrets.

The selection itself is one small file and one environment variable. Terraform writes the currently selected workspace into .terraform/environment, with no trailing newline, and when that file is missing you are on default. TF_WORKSPACE, if anything in your shell or your pipeline set it, quietly wins over whatever you selected.

terminal
# where the selection is stored, and what overrides it
cat .terraform/environment; echo
terraform workspace show
export TF_WORKSPACE=prod
terraform workspace show
output
staging
staging
prod
A selected workspace is a mode you can forget you are in
Nothing in your shell prompt tells you which workspace is live. You select prod to check one thing, get pulled into a meeting, come back and run terraform destroy on what you are certain is scratch. TF_WORKSPACE is quieter still: a variable exported early in a CI job (Continuous Integration, the automated pipeline that builds and tests every commit) silently repoints every command after it, which is why HashiCorp recommends it for non-interactive use only. It also makes terraform workspace select refuse to run, reporting that the selection is currently overridden by the environment variable, and that message is easy to skim past at speed. Print terraform workspace show immediately before anything destructive, and make your pipeline print it into the log where an investigator can find it a month later.

A Directory per Environment

The layout most teams land on for production is a directory per environment. Separate houses with separate locks, instead of one master keycard. Each directory holds a thin root module (the top-level .tf files Terraform actually runs) that calls the same shared modules with different inputs, plus its own backend block pointing at its own state. Shared logic stays in modules/ and gets reviewed once. Production's state can sit in a different bucket in a different cloud account, reachable only through a role that dev's pipeline is not allowed to assume. More files than workspaces. Much clearer edges.

repo layout
modules/ # shared logic, written and reviewed once
network/
web/
envs/
dev/
backend.tf # bucket acme-tfstate-dev, account 111111111111
main.tf # module "network" { source = "../../modules/network" ... }
dev.auto.tfvars # loaded automatically from this directory
staging/
...
prod/
backend.tf # bucket acme-tfstate-prod, account 222222222222
main.tf # the same modules, production inputs
guard.tf # refuses to run against the wrong account
prod.auto.tfvars
envs/prod/backend.tf
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "acme-tfstate-prod" # literal strings only: no variables here
key = "network/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true # S3-native state locking (Terraform 1.10+)
assume_role = {
# ARN (Amazon Resource Name) of the production state role.
# Dev's pipeline identity is not trusted to assume it.
role_arn = "arn:aws:iam::222222222222:role/tf-state-prod"
}
}
}

Look hard at that backend block: every value is a literal string. Terraform reads the backend before it evaluates variables, locals or any other expression, so none of those are legal inside it. That single constraint is what pushes teams toward workspaces, because one directory cannot swap buckets with a variable. The supported way around it is partial configuration: leave the changing details out of the block and hand them to init from a .tfbackend file. That keeps one root module and feeds it a different backend per run, which is the layout the next warning is about.

backends/prod.s3.tfbackend
bucket = "acme-tfstate-prod"
key = "network/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
terminal
# one root module; the backend details arrive at init time
terraform init -reconfigure -backend-config=backends/dev.s3.tfbackend
# later, same directory, different environment, and -reconfigure forgotten
terraform init -backend-config=backends/prod.s3.tfbackend
output
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing modules...
- network in modules/network
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.2.0
Terraform has been successfully initialized!
Initializing the backend...
│ Error: Backend configuration changed
│ A change in the backend configuration has been detected, which may require
│ migrating existing state.
│ If you wish to attempt automatic migration of the state, use "terraform
│ init -migrate-state".
│ If you wish to store the current configuration with no changes to the
│ state, use "terraform init -reconfigure".

That error is a fork with two exits, and they do very different things. The -reconfigure flag drops the old state association and starts clean against the new backend, which is what you want when you are switching environments. The -migrate-state flag copies the state you are currently holding into the new location. Terraform does prompt before it overwrites anything, but the prompt lands in the middle of a long init and tired people type yes. Get it wrong and production's ledger now describes dev, production's real resources look unmanaged, and the next apply cheerfully offers to build them all over again. If a directory ever serves more than one environment, wrap init in a script that always passes -reconfigure and never let a human type the bare command.

Three levels of separation between dev and prod
Workspaces
One backend
env:/prod/... sits beside env:/dev/... in one bucket
One set of credentials
whatever the shell or pipeline holds at run time
Shared code path
terraform.workspace conditionals decide behaviour
Boundary: a name you typed
a wrong selection is a wrong environment
Directory per environment
Own backend and state
separate bucket or key, separate bucket policy
Own inputs
prod.auto.tfvars, no workspace conditionals
Own reviewers
path rules deciding who must approve envs/prod
Boundary: permissions and review
still one repo, still possibly one pipeline role
Separate cloud accounts
Prod resources in their own account
the dev role cannot assume the prod role
Prod state in the prod account
dev cannot read production secrets out of state
Own pipeline identity
trusted only from the production deploy job
Boundary: the cloud provider itself
a mistake in dev cannot reach prod at all
Each rung adds a boundary that a mistake, or an attacker holding a stolen token, has to cross. Pick the lowest rung that still leaves the worst case survivable.

Variables Carry the Differences

Differences between environments belong in variables: instance sizes, replica counts, log retention, domain names, and the account ID this directory is allowed to touch. Terraform auto-loads terraform.tfvars and any file ending in .auto.tfvars from the working directory. Anything else needs -var-file on the command line. Precedence runs weakest to strongest: TF_VAR_ environment variables, then terraform.tfvars, then .auto.tfvars files in alphabetical order, then -var and -var-file in the order you wrote them, with the last one winning. Keep that order in your head. A stray dev.auto.tfvars left in a directory it does not belong in will quietly beat the values you thought you were passing, and z.auto.tfvars beats a.auto.tfvars for no reason other than the alphabet.

envs/prod/prod.auto.tfvars
env = "prod"
expected_account_id = "222222222222" # read by guard.tf below
instance_type = "m6i.xlarge"
instance_count = 6
log_retention_days = 400
# CIDR (Classless Inter-Domain Routing) notation: an address range.
# /24 here means 256 addresses, the office block.
office_cidr = "203.0.113.0/24"
# everything else is identical to dev by construction, because the code is shared
terminal
# run from envs/prod; the exit code, not the text, is what a pipeline checks
terraform plan -detailed-exitcode
echo "exit=$?"
output
data.aws_caller_identity.current: Reading...
data.aws_caller_identity.current: Read complete after 0s [id=222222222222]
aws_security_group.web: Refreshing state... [id=sg-0b91c3d2ee5417a08]
aws_instance.web[0]: Refreshing state... [id=i-04f2c9a1d77b3e5c6]
aws_instance.web[1]: Refreshing state... [id=i-0a71b5c4e9f2d8317]
No changes. Your infrastructure matches the configuration.
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
exit=0

The -detailed-exitcode flag turns plan into a monitor. Exit 0 means reality matches the code. Exit 2 means something moved underneath you. Exit 1 means the run itself failed. Schedule it per environment and you have drift detection for free: someone opening a security group by hand in the web console shows up as exit 2 in the nightly job, in the environment where it happened, with a plan that names the resource and the exact attribute that changed.

Guards That Fail the Plan

Layout gives you separate ledgers. It does nothing about a person running the production directory with dev credentials, or the reverse. For that you want a guard inside the configuration itself, one that fails during plan, before a single API call changes anything. It is a bouncer checking the name on the door against the name on your wristband. Ask the provider who it thinks it is, compare that against a value declared in this directory's own variables, and refuse to go on when the two disagree.

envs/prod/guard.tf
variable "expected_account_id" {
type = string
description = "The only AWS account this directory may change."
}
data "aws_caller_identity" "current" {}
resource "terraform_data" "account_guard" {
input = var.expected_account_id
lifecycle {
precondition {
condition = data.aws_caller_identity.current.account_id == var.expected_account_id
error_message = "Wrong AWS account: credentials point at ${data.aws_caller_identity.current.account_id}, this directory manages ${var.expected_account_id}."
}
}
}
terminal
# the classic 2am mistake: right directory, wrong credentials
AWS_PROFILE=dev-admin terraform plan
output
data.aws_caller_identity.current: Reading...
data.aws_caller_identity.current: Read complete after 0s [id=111111111111]
│ Error: Resource precondition failed
│ on guard.tf line 13, in resource "terraform_data" "account_guard":
│ 13: condition = data.aws_caller_identity.current.account_id == var.expected_account_id
│ ├────────────────
│ │ data.aws_caller_identity.current.account_id is "111111111111"
│ │ var.expected_account_id is "222222222222"
│ Wrong AWS account: credentials point at 111111111111, this directory
│ manages 222222222222.

Two details make that guard worth the twenty lines. A lifecycle precondition is a hard error, so the plan stops and nothing downstream runs. A check block looks similar and behaves differently: it emits a warning and lets the run continue, which makes it exactly the wrong tool for stopping a mistake. And because the guard is an ordinary resource in the configuration, Terraform evaluates it on every plan and every apply in that directory, including the 3am pipeline run that nobody is watching.

The second guard is the saved plan. Running terraform plan -out=tfplan writes the exact set of changes to a file, and terraform apply tfplan applies that file instead of recomputing anything, which closes the window where the world or the code can shift between review and apply. Between those two steps, read the plan as JSON (JavaScript Object Notation, the same plan in a machine-readable form) and make policy decisions in code with jq, a command-line filter for JSON. First rule worth writing: no deletions in production without a human saying so out loud. Note that a replacement records both delete and create in its actions list, so this filter catches those too, which is almost always what you want.

terminal
# review the exact plan, then apply that file rather than a fresh guess
terraform plan -out=tfplan
terraform show -json tfplan | jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address'
output
Plan: 2 to add, 1 to change, 2 to destroy.
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"
aws_db_instance.primary
aws_s3_bucket.audit_logs

What a Reviewer Greps For

Workspaces invite conditionals in shared code, and conditionals in shared code are where environment-specific behaviour hides from reviewers. This is the first grep (a search for a pattern across many files) to run on any Terraform repository you inherit.

terminal
# what environment-specific behaviour is hiding in shared modules?
grep -rn 'terraform.workspace' --include='*.tf' .
output
./modules/network/main.tf:37: count = terraform.workspace == "prod" ? 1 : 0
./modules/web/main.tf:52: ingress_cidr = terraform.workspace == "dev" ? "0.0.0.0/0" : var.office_cidr

Line 37 means every environment except production runs without flow logs (the record of which address talked to which inside your VPC, or Virtual Private Cloud, the walled-off network your resources live in), so staging never exercises the logging and detection you are counting on in production. Line 52 means one wrong workspace name opens a security group to the entire internet. Both of those arrived in a pull request titled "small module tweak", in files that look like plumbing. With a directory per environment, a change to production has to touch files under envs/prod/, so a CODEOWNERS file (the rules telling your git host who must approve changes to which paths) can force the right eyes onto it, and git log --stat -- envs/prod answers "who changed production, and when" in one command.

.github/CODEOWNERS
# who must approve a change to each path
/modules/ @acme/platform
/envs/dev/ @acme/platform
/envs/staging/ @acme/platform
/envs/prod/ @acme/platform @acme/security
Quick check
01Your team moves from Terraform workspaces to a directory per environment, but keeps one state bucket for all environments and one pipeline role that can write every environment's state and change every environment's resources. What have you actually gained?
Incorrect — Not true. Shared code no longer needs terraform.workspace conditionals, and a production change now touches production-only files, which reviewers and CODEOWNERS rules can act on.
Correct — Layout buys clarity and review boundaries. Blast radius is set by credentials, and one role that can write everything can still wreck everything.
Incorrect — State separation is not permission separation. Permissions come from the bucket policy and the identity running the plan, and neither of those changed.
Incorrect — Smaller state does make refresh and plan faster, but that is a side effect, not the isolation question this change was meant to answer.
02You want to stop terraform plan in your production directory the instant it detects the wrong AWS account, before any API call runs. A teammate proposes a check block instead of a lifecycle precondition. Why is a check block the wrong tool for this?
Incorrect — check blocks are evaluated during plan as well; the real problem is what they do when the condition fails, not when they run.
Correct — a lifecycle precondition is a hard error that stops the plan and everything downstream, while a check block only warns and keeps going.
Incorrect — check blocks can contain their own scoped data sources; the data source is not the limitation.
Incorrect — nothing ties a check block to a particular workspace; this restriction is invented.
03One directory serves both dev and prod through partial backend configs, and you used it against dev earlier. You now run terraform init -backend-config=backends/prod.s3.tfbackend without -reconfigure, hit 'Error: Backend configuration changed', and a tired teammate resolves it by running terraform init -migrate-state and typing yes. What is the result?
Correct — -migrate-state copies your current (dev) state into the new location, so the next apply cheerfully offers to rebuild all of prod.
Incorrect — Terraform prompts once and then proceeds; there is no cross-account migration block.
Incorrect — that describes -reconfigure; -migrate-state does the opposite by carrying the old state across.
Incorrect — migration copies one state over the other, it does not merge two ledgers.
terminal
# preflight, run from envs/prod: what is this run about to touch?
terraform workspace show
terraform state list | wc -l
# sts = Security Token Service; this asks the cloud who the credentials belong to
aws sts get-caller-identity --query Account --output text
output
default
64
222222222222

On a directory-per-environment layout that first line prints default every single time, and that is the whole point: there is no mode to be in, so there is nothing to forget. The other two lines are the ones that change between runs, and they are the ones worth failing a pipeline over. Wire them into the job that runs before plan. If the account number is not the one this directory expects, stop there and page someone.

Try this

Run terraform workspace new staging on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a selected workspace is a mode you can forget you are in. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related