Workspaces & environments
dev, staging, prod from one codebase.
You wrote one Terraform configuration that builds a network, a database, and a few servers. Now three groups want it. Developers want a small copy they can break before lunch. QA (quality assurance, the people who test the software before customers see it) wants a staging copy shaped like production but cheaper. Production has to stay up. One recipe, three kitchens. Terraform gives you two established ways to run that recipe in three places, and picking between them is a security decision, because it sets how hard it is to change production while you sincerely believe you are changing dev.
The two patterns are CLI workspaces (CLI is the command line interface, the terminal where you type terraform) and directory-per-environment. A workspace keeps one copy of the code and swaps the record of what exists underneath it, so the environment you are working on is a setting you carry around with you. Directory-per-environment gives each environment its own folder, its own storage for that record, and its own variable file, so the environment becomes a place you have to walk into. Both are legitimate. Both are running production somewhere right now. They fail in very different ways, and the failure modes are what should decide it.
What a workspace actually is
Start with state, because a workspace is only a trick played on state. State is Terraform's ledger: a JSON file (JavaScript Object Notation, a plain text format for structured data) recording every resource Terraform created and what that resource looked like the last time Terraform checked. A workspace is a named slot for one of those ledgers. Same recipe binder, same pantry, a different set of sticky notes about what is currently in each kitchen. The code, the provider versions (a provider is the plugin that talks to the cloud's API, or application programming interface, on Terraform's behalf), the backend (the shared storage the ledger lives in, usually a cloud bucket), and the credentials are all identical across workspaces. Only the ledger changes.
Every configuration starts with a workspace called default. It is always there, and you cannot delete it. Making a second workspace copies nothing. The new one starts empty, which Terraform tells you in mildly alarming terms, because plenty of people have expected it to clone what they already had.
$ terraform workspace new staging
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.
Creating a workspace and selecting one are separate steps, and once you have selected one the choice goes invisible. Nothing in the output of a later plan or apply reminds you where you are pointed. The asterisk in terraform workspace list and the single word that comes back from terraform workspace show are the whole signal. That is a thin thread to hang production on.
$ terraform workspace new prod >/dev/null # same command, output hidden$ terraform workspace select staging$ terraform workspace list$ terraform workspace show
Switched to workspace "staging".defaultprod* stagingstaging
$ terraform workspace select prd # one keystroke short of production
Workspace "prd" doesn't exist.You can create this workspace with the "new" subcommandor include the "-or-create" flag with the "select" subcommand.
That refusal is a small gift. The -or-create flag exists so a pipeline's first run does not fail on a workspace nobody has made yet, and it is also how a typo quietly creates a fourth environment that nobody owns and nobody destroys. If your CI (continuous integration, the automated system that runs your build and deploy steps) calls select -or-create with a branch name, check that name against a list you control before Terraform ever sees it. One piece of vocabulary while we are here. HCP Terraform (HashiCorp Cloud Platform, formerly Terraform Cloud) also has a thing called a workspace, and it is a different animal. There a workspace is a whole configuration with its own variables, credentials, and run history, much closer to the directory pattern further down this page than to the CLI workspaces described here.
Your code can read the name of the current workspace through the terraform.workspace expression, which is how one configuration produces one server in dev and three in prod. The obvious form, count = terraform.workspace == "prod" ? 3 : 1, works and fails quietly: a workspace called prodd falls through to the else branch and gets dev sizing. A map keyed by environment fails loudly instead, at plan time, before anything is built.
locals {sizing = {dev = { count = 1, type = "t3.micro" }staging = { count = 2, type = "t3.small" }prod = { count = 3, type = "m6i.large" }}env = local.sizing[terraform.workspace] # unknown workspace = hard error}resource "aws_instance" "web" {count = local.env.countami = var.ami_idinstance_type = local.env.type}
$ terraform workspace new prodd # a typo in a pipeline variable$ terraform plan
Created and switched to workspace "prodd"!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.╷│ Error: Invalid index││ on sizing.tf line 8, in locals:│ 8: env = local.sizing[terraform.workspace] # unknown workspace = hard error│ ├────────────────│ │ local.sizing is object with 3 attributes│ │ terraform.workspace is "prodd"││ The given key does not identify an element in this collection value.╵
Loud beats quiet. The typo costs you a failed plan instead of a dev-sized production, and the message names the bad value out loud.
Where the state actually lands
Workspaces are not magic. They are a path prefix. With the local backend, the default workspace keeps its ledger at ./terraform.tfstate, and every other workspace gets a folder underneath terraform.tfstate.d. That asymmetry catches people out, and it is a theme: default is the odd one out everywhere in this feature.
$ ls -l terraform.tfstate$ find terraform.tfstate.d -type f
-rw-r--r-- 1 dana dana 12894 Jul 19 09:12 terraform.tfstateterraform.tfstate.d/prod/terraform.tfstateterraform.tfstate.d/staging/terraform.tfstate
With a remote backend the same trick moves into the bucket. The S3 backend (Simple Storage Service, Amazon's object storage) takes the key you configured and puts env:/<workspace>/ in front of it for every workspace except default. You can change that prefix with workspace_key_prefix, and almost nobody does, so the layout below is what you will find in most repositories.
$ aws s3 ls s3://acme-tf-state --recursive --human-readable
2026-07-19 14:22:08 39.3 KiB env:/prod/network/terraform.tfstate2026-07-19 09:41:52 18.5 KiB env:/staging/network/terraform.tfstate2026-07-02 11:04:19 41.2 KiB network/terraform.tfstate
The unprefixed object at the bottom is the default workspace. Read those three lines the way a security reviewer would. Three environments, one bucket, one bucket policy, one lock, one set of credentials that reaches all of them. That single fact is the entire security story for workspaces.
One backend, one blast radius
State is stored as readable JSON, and it contains whatever the provider handed back, including values you never meant to write down: a generated database password, a private key, a token minted by a resource. Encryption at rest does not save you here. Server-side encryption is decrypted transparently for anyone the bucket already allows to read the object, so it defends against someone walking off with a disk and does nothing about an over-broad policy. A credential that can write dev state can usually read prod state. Watch how short the path is.
$ terraform workspace select prod$ terraform state pull | jq -r '.resources[] | select(.type=="aws_db_instance") | .instances[].attributes.password'
Switched to workspace "prod".r7Qk2vXn0pLd3Zt
No exploit, no privilege escalation, no alert. One command from a laptop that was only ever meant to touch dev. (jq is a small command line tool for pulling values out of JSON.) When you review an environment layout, ask that question first: which credentials can read which state? If the answer is that everyone can read everything, workspaces are not giving you the separation your org chart claims. Two moves fix most of it. Scope the credentials to the prefix with an IAM policy (Identity and Access Management, the AWS service that decides which principal may call which API), like the one below. Then switch on CloudTrail data events for the bucket (CloudTrail is AWS's log of API calls, and data events add the individual object reads), so that a read of the prod object by anything other than the prod pipeline role raises an alert instead of silence.
{"Version": "2012-10-17","Statement": [{"Sid": "ListSoTerraformCanEnumerateWorkspaces","Effect": "Allow","Action": "s3:ListBucket","Resource": "arn:aws:s3:::acme-tf-state"},{"Sid": "DevStateReadWrite","Effect": "Allow","Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],"Resource": "arn:aws:s3:::acme-tf-state/env:/dev/*"},{"Sid": "NeverProdAndNeverDefault","Effect": "Deny","Action": "s3:*","Resource": ["arn:aws:s3:::acme-tf-state/env:/prod/*","arn:aws:s3:::acme-tf-state/network/terraform.tfstate"]}]}
The second entry in that deny list is the one people forget. The default workspace lives at the bare key with no env:/ prefix, so a policy written only around env:/prod/* leaves it open. Three more details you should know. The ListBucket grant is needed for terraform workspace list to work at all, and it lets dev read the names of every prod key, which is why the deny on the objects has to be explicit. Terraform 1.10 and later can lock state with a small .tflock object written next to the state file, so the same prefix in the policy covers the lock too, and the older DynamoDB table (Amazon's key-value database) is on its way out. And all of this holds only while everyone uses the committed backend configuration. Anyone who can edit backend.tf, or pass -backend-config on the command line, can point Terraform at any key their credentials allow. The IAM policy is the boundary. The repository layout is a convention.
$ terraform workspace select default # you cannot delete the one you are on$ terraform workspace delete staging
Switched to workspace "default".╷│ Error: Workspace is not empty││ Workspace "staging" is currently tracking the following resource instances:│ - module.network.aws_vpc.this│ - module.network.aws_subnet.private[0]│ - module.network.aws_subnet.private[1]││ Deleting this workspace would cause Terraform to lose track of any│ associated remote objects, which would then require you to delete those│ remote objects manually. To proceed anyway, use the -force option.╵
Directory per environment
The other pattern turns the environment into a path. One folder per environment, each holding its own backend block, its own variable file (.tfvars, plain values for the configuration's inputs), and a thin main.tf that calls shared modules for the actual resources. A module is a reusable folder of Terraform code you call like a function. Separate kitchens with separate pantry keys, one shared recipe book on the wall. The resource code is still written once. What gets duplicated is the small amount of wiring that defines a boundary, and that is exactly the part you want a human to read line by line in a pull request.
$ tree -L 3 infra/
infra/├── environments│ ├── dev│ │ ├── backend.tf│ │ ├── dev.tfvars│ │ ├── main.tf│ │ └── variables.tf│ └── prod│ ├── backend.tf│ ├── main.tf│ ├── prod.tfvars│ └── variables.tf└── modules├── db│ └── main.tf├── network│ └── main.tf└── web└── main.tf7 directories, 11 files
terraform {required_version = ">= 1.10"backend "s3" {bucket = "acme-tfstate-prod" # a separate bucket, not a prefixkey = "network/terraform.tfstate"region = "eu-west-1"profile = "prod-state" # state credentials pinned here, not taken from the shellencrypt = trueuse_lockfile = true # native S3 locking, Terraform 1.10+}}
module "network" {source = "../../modules/network" # the exact module dev callsenvironment = "prod"cidr = var.cidraz_count = var.az_count}
$ terraform -chdir=infra/environments/prod init
Initializing modules...- network in ../../modules/networkInitializing the backend...Successfully configured the backend "s3"! Terraform will automaticallyuse this backend unless the backend configuration changes.Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 6.0"...- Installing hashicorp/aws v6.7.0...- Installed hashicorp/aws v6.7.0 (signed by HashiCorp)Terraform has created a lock file .terraform.lock.hcl to record the providerselections it made above. Include this file in your version control repositoryso that Terraform can guarantee to make the same selections by default whenyou run "terraform init" in the future.Terraform has been successfully initialized!
$ terraform -chdir=infra/environments/prod plan -var-file=prod.tfvars
Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:+ createTerraform will perform the following actions:# module.network.aws_vpc.this will be created+ resource "aws_vpc" "this" {+ arn = (known after apply)+ cidr_block = "10.20.0.0/16"+ default_security_group_id = (known after apply)+ enable_dns_hostnames = true+ id = (known after apply)+ instance_tenancy = "default"+ main_route_table_id = (known after apply)+ tags = {+ "Name" = "prod-network"}+ tags_all = {+ "Name" = "prod-network"}}Plan: 7 to add, 0 to change, 0 to destroy.
-chdir is a global option, so it goes before the subcommand, not after. It changes Terraform's working directory before anything else happens, which is why -var-file=prod.tfvars resolves inside the prod folder rather than wherever you happened to be standing. It is the honest way to drive this from the repository root, and it writes the environment into the command itself, which matters when somebody reads your CI logs six months from now trying to work out what touched production.
What people complain about here is duplication, and the complaint is fair: backend.tf and the tfvars file exist once per environment. Terragrunt was built to squeeze that out (DRY, don't repeat yourself), generating each environment's backend block from one template. A lighter option keeps a single root module and hands Terraform a per-environment backend file at init time, terraform init -backend-config=env/prod.hcl. It works. It also has a sharp edge that has cost people their production state.
Run init with a different -backend-config in a directory whose .terraform folder already remembers another backend, and Terraform notices the change and offers to copy the existing state to the new location. Say yes at the wrong moment and you have written dev state over prod state, and the next apply will act on it without complaint. Use -reconfigure when you want the new settings and no copying at all. Use -migrate-state only when moving state is genuinely the plan. In CI, delete the .terraform directory between environments, or keep separate directories so the question never comes up.
Guardrails that stop the wrong apply
No layout stops a tired human at 18:40 on a Friday. Layout changes how likely the mistake is. Guardrails decide whether the mistake reaches the cloud. The cheapest guardrail is a doorman who checks the house number before letting anyone say a word. Add a providers.tf to the prod folder that names the only account this code is allowed to touch.
provider "aws" {region = "eu-west-1"allowed_account_ids = ["444455556666"] # prod, and nowhere else}
$ export AWS_PROFILE=dev # wrong credentials, right directory$ terraform -chdir=infra/environments/prod plan
╷│ Error: Incorrect account ID││ with provider["registry.terraform.io/hashicorp/aws"],│ on providers.tf line 1, in provider "aws":│ 1: provider "aws" {││ AWS account ID not allowed: 111122223333╵
The backend pinned its own profile, so the state loaded normally and the provider is where the wrong credentials got caught. The check runs while the provider configures itself. It asks STS (Security Token Service, the AWS service that issues temporary credentials and will also tell you whose credentials you are holding) which account these keys belong to, compares, and stops. No resources were read, no state was written, no plan was produced. Pair it with prevent_destroy on the resources you cannot afford to lose, and a plan that would delete your database errors out instead of politely offering you the option. One limitation to know: arguments inside a lifecycle block cannot reference variables, so prevent_destroy is a literal true or nothing at all. Keep the flag on a prod-only resource rather than in a module both environments share, which is why the prod database is defined here and not behind modules/db.
resource "aws_db_instance" "orders" {identifier = "prod-orders"engine = "postgres"instance_class = "db.m6g.large"allocated_storage = 100username = "app"manage_master_user_password = true # password lands in Secrets Manager, not in statedeletion_protection = truelifecycle {prevent_destroy = true # any plan proposing a destroy fails here}}
That manage_master_user_password line is the answer to the state file you read earlier. Let RDS generate and hold the password in Secrets Manager, and the only thing state records is the identifier of the secret. Anyone pulling state gets a reference, not a login.
The strongest control is organizational, and it makes the whole workspace-versus-directory argument far less frightening: humans do not hold production credentials. The prod apply runs in a pipeline that assumes a prod role through OIDC (OpenID Connect, an identity protocol that lets a CI job trade a short-lived signed token for cloud credentials, so no long-lived keys sit on any disk), and that role trusts exactly one repository, one branch, and one workflow. A developer sitting on the wrong workspace then gets AccessDenied, which is a message in Slack rather than an incident channel.
One last trap, and it is the reason workspaces bite harder than folders. Your workspace selection outlives your terminal. Terraform writes it to .terraform/environment on disk, so the shell you reopen next Tuesday is still on prod, and so is the terminal tab you left open yesterday afternoon. The TF_WORKSPACE environment variable overrides that file for every command in a shell without touching it, which is handy in a pipeline and lethal in a session where you exported it an hour ago and forgot.
Verify before you touch anything
Make the invisible context visible. Because the selected workspace is a plain text file, your shell prompt can read it directly with no subprocess and no network call, and you get a permanent reminder of which kitchen you are standing in.
# Show the selected Terraform workspace in every prompt.tf_ws() {[ -s .terraform/environment ] || return # absent means the default workspaceprintf ' (tf:%s)' "$(< .terraform/environment)"}PS1='\u@\h:\w$(tf_ws)\$ '
$ cat .terraform/environment; echo # the file has no trailing newline$ aws sts get-caller-identity$ terraform plan -detailed-exitcode -var-file=prod.tfvars -out=prod.plan$ echo $?
prod{"UserId": "AROAY3LX4EXAMPLEID:deploy","Account": "444455556666","Arn": "arn:aws:sts::444455556666:assumed-role/tf-prod-apply/deploy"}module.network.aws_security_group.web: Refreshing state... [id=sg-0a91c4e2b7d3f8410]Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:~ update in-placeTerraform will perform the following actions:# module.network.aws_security_group.web will be updated in-place~ resource "aws_security_group" "web" {id = "sg-0a91c4e2b7d3f8410"name = "prod-web"~ tags = {~ "owner" = "platform" -> "payments"}~ tags_all = {~ "owner" = "platform" -> "payments"}# (8 unchanged attributes hidden)}Plan: 0 to add, 1 to change, 0 to destroy.Saved the plan to: prod.planTo perform exactly these actions, run the following command to apply:terraform apply "prod.plan"2
Three answers in about twenty seconds: which workspace is selected on disk, which account those credentials actually belong to, and what would change. -detailed-exitcode is the flag that makes the last one scriptable, returning 0 for no changes, 2 for changes, and 1 for a failure. A pipeline can plan every environment on every commit and only wake somebody when prod comes back 2. Saving the plan with -out closes the final gap, because terraform apply prod.plan carries out exactly the recorded actions without re-reading the world, and if anyone changed the state in between, the apply refuses the stale plan rather than improvising. The thing that was reviewed is the thing that runs.
If you are keeping workspaces, do one thing this week. Run aws s3 ls on your state bucket, list every principal that can call GetObject under the env:/prod/ prefix and on the unprefixed default key, and compare that list against the people you would trust to change production tonight. Whatever gap you find is your real environment boundary, and no amount of folder structure will move it.
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: deleting a workspace deletes nothing it built. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.