Workspaces & environments
dev, staging, prod from one codebase.
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.
# one configuration, several ledgers (prod already exists here)terraform workspace new stagingterraform workspace list
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 statefor this configuration.defaultprod* 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.
# every workspace's state, side by side in one bucketaws s3 ls s3://acme-tfstate --recursive
2026-07-14 09:12:44 18422 env:/prod/network/terraform.tfstate2026-07-14 09:10:02 17980 env:/staging/network/terraform.tfstate2026-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.
# where the selection is stored, and what overrides itcat .terraform/environment; echoterraform workspace showexport TF_WORKSPACE=prodterraform workspace show
stagingstagingprod
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.
modules/ # shared logic, written and reviewed oncenetwork/web/envs/dev/backend.tf # bucket acme-tfstate-dev, account 111111111111main.tf # module "network" { source = "../../modules/network" ... }dev.auto.tfvars # loaded automatically from this directorystaging/...prod/backend.tf # bucket acme-tfstate-prod, account 222222222222main.tf # the same modules, production inputsguard.tf # refuses to run against the wrong accountprod.auto.tfvars
terraform {required_version = ">= 1.10"backend "s3" {bucket = "acme-tfstate-prod" # literal strings only: no variables herekey = "network/terraform.tfstate"region = "eu-west-1"encrypt = trueuse_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.
bucket = "acme-tfstate-prod"key = "network/terraform.tfstate"region = "eu-west-1"encrypt = trueuse_lockfile = true
# one root module; the backend details arrive at init timeterraform init -reconfigure -backend-config=backends/dev.s3.tfbackend# later, same directory, different environment, and -reconfigure forgottenterraform init -backend-config=backends/prod.s3.tfbackend
Initializing the backend...Successfully configured the backend "s3"! Terraform will automaticallyuse this backend unless the backend configuration changes.Initializing modules...- network in modules/networkInitializing provider plugins...- Reusing previous version of hashicorp/aws from the dependency lock file- Using previously-installed hashicorp/aws v6.2.0Terraform 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.
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.
env = "prod"expected_account_id = "222222222222" # read by guard.tf belowinstance_type = "m6i.xlarge"instance_count = 6log_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
# run from envs/prod; the exit code, not the text, is what a pipeline checksterraform plan -detailed-exitcodeecho "exit=$?"
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 configurationand 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.
variable "expected_account_id" {type = stringdescription = "The only AWS account this directory may change."}data "aws_caller_identity" "current" {}resource "terraform_data" "account_guard" {input = var.expected_account_idlifecycle {precondition {condition = data.aws_caller_identity.current.account_id == var.expected_account_iderror_message = "Wrong AWS account: credentials point at ${data.aws_caller_identity.current.account_id}, this directory manages ${var.expected_account_id}."}}}
# the classic 2am mistake: right directory, wrong credentialsAWS_PROFILE=dev-admin terraform plan
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.
# review the exact plan, then apply that file rather than a fresh guessterraform plan -out=tfplanterraform show -json tfplan | jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address'
Plan: 2 to add, 1 to change, 2 to destroy.Saved the plan to: tfplanTo perform exactly these actions, run the following command to apply:terraform apply "tfplan"aws_db_instance.primaryaws_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.
# what environment-specific behaviour is hiding in shared modules?grep -rn 'terraform.workspace' --include='*.tf' .
./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.
# 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
# preflight, run from envs/prod: what is this run about to touch?terraform workspace showterraform state list | wc -l# sts = Security Token Service; this asks the cloud who the credentials belong toaws sts get-caller-identity --query Account --output text
default64222222222222
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.