Workspaces & environments
dev, staging, prod from one codebase.
A theatre company runs the same play three nights in a row. Same script, same lighting cues, same blocking. What changes between nights is the size of the room, the number of chairs, and which door the audience walks through. Infrastructure code works the same way. One set of .tf files (the plain-text files that describe your servers, buckets, and networks) says what a service looks like, and dev, staging, and prod are three performances of it at different scales.
OpenTofu, the open-source fork of Terraform, builds cloud infrastructure from those files. It hands you two separate levers for running the same play in three rooms. Workspaces keep several independent state files behind one configuration. Variable files swap the values that go into that configuration. They solve different halves of the problem, and mixing them up is where teams get hurt. Backends and remote state get their own lesson in ot-remote; here you are slicing one codebase into environments on top of whatever backend you already run.
State Is the Thing Being Separated
Before workspaces make sense, be precise about what state is. Think of a shop's stock ledger, the book that says what is on the shelves right now. State is OpenTofu's ledger: a JSON file (JavaScript Object Notation, a plain-text format for structured data) that records "I created bucket acme-app-dev, its ARN (Amazon Resource Name, the unique identifier string AWS gives every resource) is this, its settings are that." Without that ledger, OpenTofu has no idea whether a resource in your code already exists, so it would try to create everything again on every run. The ledger lives in a backend, which is wherever you told OpenTofu to keep it: a file on disk, an S3 (Simple Storage Service, Amazon's object store) bucket, a database.
One ledger means one set of real resources. If dev and prod share a state file, they are the same infrastructure wearing two names. Separating environments always comes down to separating state. Everything else in this lesson is a mechanism for doing that.
CLI Workspaces: One Config, Many Ledgers
A filing cabinet with labelled drawers. Same cabinet, same key, different drawers. A workspace is exactly that: a named, independent state instance stored inside the same backend, driven by the same configuration and the same credentials. Every project starts in a workspace called default, and most people never notice it exists.
cd ~/infra/apptofu workspace listtofu workspace new devtofu workspace new stagingtofu workspace new prod
* defaultCreated and switched to workspace "dev"!You're now on a new, empty workspace. Workspaces isolate their state,so if you run "tofu plan" OpenTofu will not see any existing statefor this configuration.Created and switched to workspace "staging"!You're now on a new, empty workspace. Workspaces isolate their state,so if you run "tofu plan" OpenTofu will not see any existing statefor this configuration.Created and switched to workspace "prod"!You're now on a new, empty workspace. Workspaces isolate their state,so if you run "tofu plan" OpenTofu will not see any existing statefor this configuration.
Read what that message actually promises. Workspaces isolate their state. That is the whole guarantee. It says nothing about credentials, nothing about network reachability, nothing about who is allowed to run what. Hold onto that, because it is the security point of the entire lesson.
The active workspace is readable inside your HCL (HashiCorp Configuration Language, the syntax OpenTofu configs are written in) as terraform.workspace. The name kept the old vendor prefix so existing configs keep working, which ot-compat covers. Fold it into resource names and tags so every environment produces distinct, self-labelling resources.
resource "aws_s3_bucket" "app" {bucket = "acme-app-${terraform.workspace}" # acme-app-dev, acme-app-pr-482tags = {Environment = terraform.workspaceManagedBy = "opentofu"}}locals {allowed_workspaces = ["dev", "staging"]is_pr_stack = startswith(terraform.workspace, "pr-")}# A doorman on the root module: no ticket, no show.resource "terraform_data" "workspace_guard" {input = terraform.workspacelifecycle {precondition {condition = contains(local.allowed_workspaces, terraform.workspace) || local.is_pr_stackerror_message = "Workspace ${terraform.workspace} is not managed by this root. Use dev, staging, or a pr-* stack."}}}
Two things in that file are worth slowing down on. terraform_data is a built-in resource that does nothing at all, which makes it a convenient place to hang a rule. The precondition is the doorman: OpenTofu evaluates it while building the plan, and a failed precondition aborts the run before anything in the cloud is touched. You will see this guardrail written with a check block instead. Do not copy that. A failing check block prints a warning and lets the apply continue, which is worse than having no guard, because it looks like protection and is not. A precondition is a hard stop.
tofu workspace select prodtofu plan -var-file=prod.tfvars
Switched to workspace "prod".Planning failed. OpenTofu encountered an error while generating this plan.╷│ Error: Resource precondition failed││ on main.tf line 21, in resource "terraform_data" "workspace_guard":│ 21: condition = contains(local.allowed_workspaces, terraform.workspace) || local.is_pr_stack│ ├────────────────│ │ local.allowed_workspaces is list of string with 2 elements│ │ local.is_pr_stack is false│ │ terraform.workspace is "prod"││ Workspace prod is not managed by this root. Use dev, staging, or a pr-* stack.╵
Switching drawers is one command. Get into the habit of confirming which drawer you are standing in before you touch anything.
tofu workspace select devtofu workspace showtofu workspace list
Switched to workspace "dev".devdefault* devprodstaging
That asterisk is the only visual signal you get. In a busy terminal it scrolls off the screen in seconds, which is exactly how people apply to the wrong environment. Put the workspace name in your shell prompt if you use workspaces daily.
Where Workspace State Actually Lives
Workspaces are not magic. They are a naming convention inside your backend, and knowing the exact layout matters the moment you write access policies. With an S3 backend, the default workspace uses your configured key verbatim. Every other workspace gets filed under an env:/ prefix in the same bucket. That prefix is only a default; workspace_key_prefix in the backend block changes it, so check the config before you write a policy against it.
aws s3 ls s3://acme-tfstate/ --recursive
2026-07-14 09:22:10 18432 app/terraform.tfstate2026-07-19 11:04:57 21980 env:/dev/app/terraform.tfstate2026-07-20 16:31:02 22415 env:/prod/app/terraform.tfstate2026-07-18 08:47:39 21104 env:/staging/app/terraform.tfstate
Read that listing like a defender. Every environment's ledger sits in one bucket, reachable by one set of credentials. State files hold resolved values: database endpoints, generated passwords, private IP (Internet Protocol) ranges, sometimes access keys a provider handed back at create time. An attacker who lands read access on that bucket does not need to break into prod. They read prod's blueprint and its secrets straight out of the JSON, then walk in the front door with them. One over-broad s3:GetObject grant on acme-tfstate exposes all four files at once.
So if you use workspaces, write policies against the real key layout instead of against a mental model of "separate environments." A policy condition on the env:/prod/ prefix is a real control. The workspace name by itself is not. OpenTofu can also encrypt state on your machine before it ever reaches the bucket, using the encryption block covered in ot-remote, which turns a stolen object into ciphertext rather than a shopping list.
Swapping the Props With Per-Environment tfvars
Separate state gives you separate resources. It does not give you different resources. Point dev and staging at the same config with the same inputs and you build two identical stacks, one of them quietly burning money at production size. The values have to come from somewhere. The clean answer is one .tfvars file per environment (a variable file, a plain list of name = value pairs), chosen by hand at plan and apply time.
environment = "staging"instance_type = "m6i.large"min_replicas = 2max_replicas = 6deletion_protection = falselog_retention_days = 90
tofu workspace select stagingtofu plan -var-file=staging.tfvars -out=staging.tfplan
Switched to workspace "staging".Acquiring state lock. This may take a few moments...aws_s3_bucket.app: Refreshing state... [id=acme-app-staging]aws_autoscaling_group.web: Refreshing state... [id=acme-web-staging]OpenTofu used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:~ update in-placeOpenTofu will perform the following actions:# aws_autoscaling_group.web will be updated in-place~ resource "aws_autoscaling_group" "web" {id = "acme-web-staging"~ min_size = 1 -> 2name = "acme-web-staging"# (9 unchanged attributes hidden)}Plan: 0 to add, 1 to change, 0 to destroy.Saved the plan to: staging.tfplanTo perform exactly these actions, run the following command to apply:tofu apply "staging.tfplan"Releasing state lock. This may take a few moments...
Two habits make this safe. Keep workspace and var-file in lockstep, always, with no exceptions for "quick" runs. And save the plan to a file with -out, then apply that file instead of re-planning at apply time. The saved plan is a signed-off artifact: what you reviewed is exactly what runs, and nobody can swap in a different var-file between the review and the change.
Because the var-file is named on the command line, the environment a run targets shows up in your shell history, your CI (continuous integration, the system that runs builds and deployments automatically) logs, and your audit trail. That is a feature. An environment nobody named is an environment nobody can review.
Walling Off Production
For prod, plenty of experienced teams throw workspaces out and give production its own root directory, its own backend, usually its own bucket, and often its own cloud account. It costs you a few more files. It buys a wall you can lean on.
tree -L 2 environments/
environments/├── dev│ ├── backend.tf│ ├── dev.tfvars│ └── main.tf├── prod│ ├── backend.tf│ ├── main.tf│ └── prod.tfvars└── staging├── backend.tf├── main.tf└── staging.tfvars3 directories, 9 files
terraform {backend "s3" {bucket = "acme-tfstate-prod" # separate bucket, separate accountkey = "app/terraform.tfstate"region = "eu-west-1"encrypt = truekms_key_id = "arn:aws:kms:eu-west-1:222233334444:alias/tfstate-prod"use_lockfile = true # S3-native locking, no DynamoDB table}}
Now the boundary is held up by things that cannot be talked around. The dev role has no permission on acme-tfstate-prod. It has no grant on that KMS (Key Management Service, the cloud service that stores and controls encryption keys) key. A broken run in dev cannot reach prod state because it cannot address prod state. The wall is made of IAM (Identity and Access Management, the permission system that decides who may call what), not of a string in a config file. The use_lockfile line earns its place too: recent OpenTofu releases take the state lock with a small object in the bucket itself, so the old DynamoDB lock table is no longer required.
Drive each root without shuffling directories using -chdir, which goes before the subcommand, never after. Relative paths on the command line resolve inside the new directory, so prod.tfvars here means environments/prod/prod.tfvars.
tofu -chdir=environments/prod inittofu -chdir=environments/prod plan -var-file=prod.tfvars
Initializing the backend...Successfully configured the backend "s3"! OpenTofu will automaticallyuse this backend unless the backend configuration changes.Initializing provider plugins...- Finding hashicorp/aws versions matching "~> 5.60"...- Installing hashicorp/aws v5.62.0...- Installed hashicorp/aws v5.62.0 (signed, key ID 0C0AF313E5FD9F80)OpenTofu has created a lock file .terraform.lock.hcl to record the providerselections it made above. Include this file in your version control repositoryso that OpenTofu can guarantee to make the same selections by default whenyou run "tofu init" in the future.OpenTofu has been successfully initialized!Acquiring state lock. This may take a few moments...aws_s3_bucket.app: Refreshing state... [id=acme-app-prod]aws_autoscaling_group.web: Refreshing state... [id=acme-web-prod]No changes. Your infrastructure matches the configuration.OpenTofu has compared your real infrastructure against your configurationand found no differences, so no changes are needed.Releasing state lock. This may take a few moments...
Verify the wall instead of assuming it. From a dev-scoped session, try to read prod state. A correct setup fails, and the failure is the proof.
AWS_PROFILE=acme-dev aws s3 ls s3://acme-tfstate-prod/app/
An error occurred (AccessDenied) when calling the ListObjectsV2 operation: User: arn:aws:sts::111122223333:assumed-role/acme-dev-deploy/tofu is not authorized to perform: s3:ListBucket on resource: "arn:aws:s3:::acme-tfstate-prod" because no identity-based policy allows the s3:ListBucket action
What Attackers Do With This
Two patterns show up over and over in real incidents, and both are cheap to defend against once you have seen them.
The first is state harvesting. Someone gets read access to a CI runner or a state bucket and pulls the JSON. They are not after your code, which is often public anyway. They want the resolved outputs: RDS (Relational Database Service, Amazon's managed database) endpoints, initial database passwords, generated tokens, subnet layouts. The defence is layered. Encrypt state at rest with a customer-managed key so that reading the object also requires a KMS grant. Scope bucket policies per environment prefix. Turn on data-event logging in CloudTrail (AWS's audit log, which records individual object reads only if you ask it to) so that a read of prod state produces a record you can alert on instead of silence.
The second is a poisoned pipeline that flips the target. A merged change edits the CI job so the workspace select or the -var-file quietly points at prod, while the pull request title says "bump dev instance size." The defence is to stop letting the pipeline choose. Pin the environment to the branch or to a protected CI environment with its own scoped role, require a human approval before the prod job runs, and treat any diff to backend configuration or workspace selection as a security-relevant change that needs a second reviewer.
For the everyday version of the same risk, a short pre-apply check in CI catches the mismatch before anything moves.
#!/usr/bin/env bashset -euo pipefailWANT="${1:?usage: guard-env.sh <environment>}"HAVE="$(tofu workspace show)"if [[ "$HAVE" != "$WANT" ]]; thenecho "REFUSING: workspace is '$HAVE' but this job targets '$WANT'" >&2exit 1fiif [[ ! -f "${WANT}.tfvars" ]]; thenecho "REFUSING: ${WANT}.tfvars not found" >&2exit 1fiecho "OK: workspace=$HAVE varfile=${WANT}.tfvars"
tofu workspace select devbash scripts/guard-env.sh prod; echo "exit=$?"
Switched to workspace "dev".REFUSING: workspace is 'dev' but this job targets 'prod'exit=1
Deleting Environments Without Losing Them
The pleasant half of throwaway workspaces is teardown. Destroy the resources, then remove the drawer. Note the order in the commands below: you cannot delete the workspace you are currently standing in, so switch away first. OpenTofu also refuses to delete a workspace whose state still tracks live resources, which is a guardrail worth knowing about. Pull-request stacks run on the dev sizing file on purpose, so dev.tfvars is the right var-file here.
tofu workspace select pr-482tofu destroy -var-file=dev.tfvars -auto-approvetofu workspace select defaulttofu workspace delete pr-482
Switched to workspace "pr-482".aws_s3_bucket.app: Refreshing state... [id=acme-app-pr-482]Plan: 0 to add, 0 to change, 1 to destroy.aws_s3_bucket.app: Destroying... [id=acme-app-pr-482]aws_s3_bucket.app: Destruction complete after 2sDestroy complete! Resources: 1 destroyed.Switched to workspace "default".Deleted workspace "pr-482"!
Run tofu workspace delete on a workspace that still holds resources and you get a refusal telling you the state is not empty. The -force flag overrides that refusal and orphans every resource in that state: still running, still billing, now invisible to OpenTofu. Destroy first, always.
tofu workspace select prod by mistake and applies a change that was reviewed for dev. What actually prevented, or failed to prevent, the change from landing in production?Start with workspaces for anything disposable: one per pull request, torn down at merge. Give production its own directory, its own bucket, and its own role from day one, before there is anything in it worth protecting. Then prove the wall holds by trying to read prod state with dev credentials and confirming you get AccessDenied.
Try this
Run tofu workspace list 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: workspaces are not a security boundary. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.