CoursesOpenTofuWorkspaces & environments

Workspaces & environments

dev, staging, prod from one codebase.

Intermediate10 min · lesson 8 of 12

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.

terminal
cd ~/infra/app
tofu workspace list
tofu workspace new dev
tofu workspace new staging
tofu workspace new prod
output
* default
Created 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 state
for 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 state
for 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 state
for 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.

main.tf
resource "aws_s3_bucket" "app" {
bucket = "acme-app-${terraform.workspace}" # acme-app-dev, acme-app-pr-482
tags = {
Environment = terraform.workspace
ManagedBy = "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.workspace
lifecycle {
precondition {
condition = contains(local.allowed_workspaces, terraform.workspace) || local.is_pr_stack
error_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.

terminal
tofu workspace select prod
tofu plan -var-file=prod.tfvars
output
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.

terminal
tofu workspace select dev
tofu workspace show
tofu workspace list
output
Switched to workspace "dev".
dev
default
* dev
prod
staging

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.

terminal
aws s3 ls s3://acme-tfstate/ --recursive
output
2026-07-14 09:22:10 18432 app/terraform.tfstate
2026-07-19 11:04:57 21980 env:/dev/app/terraform.tfstate
2026-07-20 16:31:02 22415 env:/prod/app/terraform.tfstate
2026-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.

staging.tfvars
environment = "staging"
instance_type = "m6i.large"
min_replicas = 2
max_replicas = 6
deletion_protection = false
log_retention_days = 90
terminal
tofu workspace select staging
tofu plan -var-file=staging.tfvars -out=staging.tfplan
output
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 execution
plan. Resource actions are indicated with the following symbols:
~ update in-place
OpenTofu 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 -> 2
name = "acme-web-staging"
# (9 unchanged attributes hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
Saved the plan to: staging.tfplan
To 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.

terminal
tree -L 2 environments/
output
environments/
├── dev
│ ├── backend.tf
│ ├── dev.tfvars
│ └── main.tf
├── prod
│ ├── backend.tf
│ ├── main.tf
│ └── prod.tfvars
└── staging
├── backend.tf
├── main.tf
└── staging.tfvars
3 directories, 9 files
environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate-prod" # separate bucket, separate account
key = "app/terraform.tfstate"
region = "eu-west-1"
encrypt = true
kms_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.

terminal
tofu -chdir=environments/prod init
tofu -chdir=environments/prod plan -var-file=prod.tfvars
output
Initializing the backend...
Successfully configured the backend "s3"! OpenTofu will automatically
use 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 provider
selections it made above. Include this file in your version control repository
so that OpenTofu can guarantee to make the same selections by default when
you 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 configuration
and 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.

terminal
AWS_PROFILE=acme-dev aws s3 ls s3://acme-tfstate-prod/app/
output
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
Workspaces are not a security boundary
Every workspace shares one backend and, more importantly, one set of provider credentials. terraform.workspace is a string your config reads; it stops nothing at the permission layer. A stray tofu workspace select prod followed by an apply reaches production with the exact keys and region the config already holds. Worse, an apply against the wrong workspace can look completely normal in the plan output unless your resource names embed the workspace, because resources are addressed by type and name, not by environment. If losing an environment would be a genuine incident, give it its own root, its own bucket, and its own credentials, and let IAM enforce the wall instead of a variable.
Workspace or separate root?
Would losing this environment be a real incident?
no
CLI workspace
Throwaway pull-request stacks, dev sandboxes: same backend, same creds, cheap to create and destroy
yes
Separate root + backend
Prod, regulated tenants: own bucket, own KMS key, own IAM role, dev literally cannot address it

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.

scripts/guard-env.sh
#!/usr/bin/env bash
set -euo pipefail
WANT="${1:?usage: guard-env.sh <environment>}"
HAVE="$(tofu workspace show)"
if [[ "$HAVE" != "$WANT" ]]; then
echo "REFUSING: workspace is '$HAVE' but this job targets '$WANT'" >&2
exit 1
fi
if [[ ! -f "${WANT}.tfvars" ]]; then
echo "REFUSING: ${WANT}.tfvars not found" >&2
exit 1
fi
echo "OK: workspace=$HAVE varfile=${WANT}.tfvars"
terminal
tofu workspace select dev
bash scripts/guard-env.sh prod; echo "exit=$?"
output
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.

terminal
tofu workspace select pr-482
tofu destroy -var-file=dev.tfvars -auto-approve
tofu workspace select default
tofu workspace delete pr-482
output
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 2s
Destroy 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.

Quick check
01Your team uses CLI workspaces named dev, staging, and prod in one S3 backend, with one deploy role. An engineer runs 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?
Correct — Workspace isolation stops at the state file. The provider credentials, region, and permissions are identical across workspaces, so the apply reaches prod resources normally.
Incorrect — terraform.workspace and the var-file are unrelated inputs. OpenTofu never cross-checks them, so nothing errors on a mismatch unless you write that check yourself.
Incorrect — The env:/ prefix is a naming convention inside the same bucket, not an access control. The same credentials read and write all of those keys.
Incorrect — Switching workspaces needs no re-init, because the backend is unchanged. The switch is instant and silent.
02The workspace guard is written as a lifecycle precondition on a terraform_data resource. A teammate wants to rewrite it as a check block instead. What is the practical difference?
Incorrect — timing is not the issue, and a check block does not stop the run at all.
Correct — the lesson calls the check-block version worse than no guard, because it looks like protection while letting the apply through.
Incorrect — only the precondition aborts; the check block downgrades the same failure to a warning.
Incorrect — check blocks can read terraform.workspace; the difference is enforcement, not what they can reference.
03A throwaway pr-482 workspace still tracks a live S3 bucket. Someone runs tofu workspace delete pr-482, gets a refusal that the state is not empty, then re-runs it with -force and it succeeds. What is the state of that bucket now?
Incorrect — -force does not destroy anything; it only overrides the not-empty refusal.
Incorrect — -force is precisely what overrides that block, so the deletion goes through.
Correct — -force drops the state that tracked the bucket without destroying it, leaving a live resource nothing manages.
Incorrect — deleting a workspace discards its state; the resources are not migrated anywhere.

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.

Related