Skip to content
intermediate Phase 9 · Workspaces and Environment Strategy

Workspace Basics with the CLI

Creating, switching, and listing workspaces and understanding state isolation.

50m
0 problems
Topic Progress 0%

Creating and Switching Workspaces: What Actually Changes on Disk

You have one directory of Terraform and three tiers to serve — dev, staging, production. You do not want three copies of the configuration (not yet); you want three separate states. That is the whole trick, and the official docs put it bluntly: workspaces are technically equivalent to renaming your state file. Everything else is bookkeeping around that rename.

Five commands cover the entire feature:

terraform workspace list            # *default   dev   staging   production
terraform workspace new staging     # Created and switched to workspace "staging"!
terraform workspace select staging  # Switched to workspace "staging".
terraform workspace show            # staging
terraform workspace delete staging  # refused while the workspace tracks resources

Read the outputs closely. list marks the active workspace with * and always shows default. new both creates and switches to the workspace — there is no second confirm step. select fails on an unknown name unless you pass -or-create. delete refuses while the workspace still tracks resources unless you add -force, and default itself can never be deleted: it is the workspace you have been using all along without knowing it.

Where does the second state file live? With the local backend, Terraform creates a directory for you:

terraform.tfstate            # the default workspace — the file you already had
terraform.tfstate.d/
├── dev/terraform.tfstate
├── staging/terraform.tfstate
└── production/terraform.tfstate

With a remote backend the layout belongs to the backend, and knowing it lets you read the bucket console with confidence. The S3 backend stores the default workspace's state at exactly the key you configured, and every other workspace under a prefix — env: by default:

s3://acme-tfstate/main/terraform.tfstate                 # default workspace
s3://acme-tfstate/env:/staging/main/terraform.tfstate    # workspace "staging"

workspace_key_prefix replaces env: when that prefix clashes with another team's convention. Notice the asymmetry: the default workspace is special-cased in the storage layout — no prefix at all — which is one more reason to give real environments named workspaces and leave default for throwaway experiments.

Your current selection is local bookkeeping, not remote state. Terraform writes the name into .terraform/environment inside the ignored .terraform directory, and every command in that directory reads it back. Two consequences bite routinely. A fresh clone plus terraform init drops you onto default, a silent reset that ends with an apply against the wrong state. And the selection is per directory, not per shell: switch workspaces in one terminal and every other terminal pointed at that directory switches with it.

Pipelines select a workspace without a prompt using TF_WORKSPACE:

TF_WORKSPACE=staging terraform apply -input=false -auto-approve

HashiCorp's own docs warn that this variable "is recommended only for non-interactive usage, since in a local shell environment it can be easy to forget the variable is set and apply changes to the wrong state." Keep it in the pipeline definition, never in your shell profile.

Switching workspaces is cheap precisely because so little moves. Hold on to this split whenever you reason about blast radius:

Changes when you switch workspace Does not change
The state file read and written Every .tf file
The lock object held in the backend .terraform.lock.hcl
The selection recorded in .terraform/environment Provider plugins and the module cache
The value of terraform.workspace The backend block itself

The switch changes which world you are editing, never the description of that world — which is also why a wrong-workspace mistake cannot be caught in code review: the pull request diff is byte-identical no matter which workspace the next apply lands in.

None of this runs before terraform init has configured the backend — and on a locked-down network init also has to source provider plugins from a filesystem or network mirror, exactly the ground covered by Provider Mirrors for Air-gapped Networks. Once init succeeds, workspace commands are pure state bookkeeping: the configuration on disk never changes, so nothing about a workspace switch can be reviewed in a pull request. That is a strength — zero config churn — and it is also the reason the next topic has to ask whether this mechanism is the right isolation tool for permanent tiers, which is where Workspace Design Tradeoffs picks up.

Branching One Configuration with terraform.workspace

A workspace is not only a state filename; it is a value your configuration can read. terraform.workspace evaluates to the current workspace name as a string — "default" when nothing was selected — and you can use it anywhere an expression is allowed. That single value is how one set of .tf files serves three environments.

The pattern that scales is a map of environment settings keyed by workspace name:

locals {
  env_config = {
    default    = { count = 1, type = "t3.micro" }
    dev        = { count = 1, type = "t3.micro" }
    staging    = { count = 2, type = "t3.small" }
    production = { count = 6, type = "m5.large" }
  }

  env = local.env_config[terraform.workspace]
}

resource "aws_instance" "web" {
  count         = local.env.count
  instance_type = local.env.type
  ami           = var.ami_id

  tags = {
    Name        = "web-${count.index}"
    Environment = terraform.workspace
  }
}

Deliberately write the lookup with no fallback default. If someone creates a workspace named prod while the map expects production, local.env_config[terraform.workspace] fails with an Invalid index error at plan time — a loud, cheap failure. Compare that with lookup(local.env_config, terraform.workspace, local.env_config.default), which quietly gives a typo'd workspace the default sizing and lets a misnamed workspace drift for weeks.

For resources that exist in exactly one tier, guard with count:

resource "aws_wafv2_web_acl" "edge" {
  count   = terraform.workspace == "production" ? 1 : 0
  name    = "edge-waf"
  scope   = "REGIONAL"
  default_action { allow {} }
}

Use terraform.workspace for tagging as well — Environment = terraform.workspace puts the workspace name on every resource in the cloud console, which is the cheapest possible defence against "which environment is this?" support tickets.

Conditional sets work the same way when the shape of the environment differs rather than its size:

locals {
  services = terraform.workspace == "production" ? toset(["api", "worker", "scheduler"]) : toset(["api"])
}

resource "aws_ecs_service" "this" {
  for_each = local.services
  name     = "payments-${each.value}"
  cluster  = aws_ecs_cluster.this.arn
}

The same value is useful outside resource bodies: publish it as an output — output "workspace" { value = terraform.workspace } — so a pipeline can assert in the plan summary which workspace it just ran against, and pass it into module inputs so child modules receive the environment instead of re-deriving it. One decision point beats N modules each reading terraform.workspace independently; when a tier is renamed, there is exactly one line to change.

The guard that actually stops a wrong-workspace run belongs in a lifecycle precondition:

resource "terraform_data" "workspace_guard" {
  input = terraform.workspace

  lifecycle {
    precondition {
      condition     = terraform.workspace == var.expected_workspace
      error_message = "This root must run against the expected workspace."
    }
  }
}

Set expected_workspace from -var-file or TF_VAR_expected_workspace in the pipeline. A failing precondition aborts the plan with an error; a check block (Terraform 1.5+) with the same assertion only emits a warning and lets the run continue. Choose deliberately which of those you want when the assertion is wrong.

Three limits are worth memorising because interviews ask about them. First, terraform.workspace cannot appear in the backend block: backend arguments are literals evaluated before the configuration graph exists, so the backend key is fixed and the backend appends the workspace prefix itself. Second, variable definition files are not workspace-aware — terraform.tfvars loads identically in every workspace, so per-environment inputs must arrive through -var-file or TF_VAR_ chosen by the caller, not through a file that lives in the directory. Third, count = terraform.workspace == "x" ? 1 : 0 branches multiply: a handful is readable, a dozen turns the configuration into a matrix of conditionals that only one engineer understands.

What Workspace Isolation Guarantees — and the Four Ways It Still Bites

The guarantee is narrow and precise: each workspace gets an independent state, and a plan in one workspace cannot see any resource tracked in another. Two roots can both declare aws_s3_bucket.data because they are writing different state objects. On S3 that means separate locks too — main/terraform.tfstate.tflock and env:/staging/main/terraform.tfstate.tflock — so a teammate applying staging never contends with your dev apply, while two people in the same workspace still queue on one lock.

Everything outside the state file is shared, and that is where the damage comes from:

Shared across every workspace Effect
Configuration files and module versions A bad change reaches every tier at once
Backend block and state credentials Anyone who can run Terraform here can read all states
Provider credentials and aliases Same account, same IAM, same blast radius
.terraform.lock.hcl Identical provider binaries everywhere

Four failure modes follow from that table.

1. Wrong-workspace apply. The diff for staging and production is structurally identical — same addresses, same module tree — so a glance at the plan tells you nothing about which state you are about to rewrite. The only in-band confirmation is terraform workspace show. Build the habit: print it before every apply, and print it in the CI log.

2. The typo that creates an environment. terraform workspace new stagging succeeds. You now have an empty state, so the next plan proposes to create the entire estate from scratch — and applying it in a shared account half-succeeds before name collisions abort the run, leaving orphaned resources behind. Guard scripts with terraform workspace list | grep -qx "$ENV" before proceeding.

3. Deleting a workspace orphans infrastructure. terraform workspace delete -force discards the state, and Terraform documents the result as dangling resources: they physically exist but are no longer manageable. Destroy first, delete second — and never use -force against a state you have not read.

4. Cross-workspace reads are explicit or absent. A terraform_remote_state data source has an optional workspace argument; omit it and you read that backend's default workspace, not the one you happen to be standing in. Mirroring the current workspace is the pattern that keeps tiers aligned:

data "terraform_remote_state" "network" {
  backend   = "s3"
  workspace = terraform.workspace
  config = {
    bucket = "acme-tfstate"
    key    = "network/terraform.tfstate"
  }
}

Leave workspace out and staging quietly consumes default, which is normally empty — the failure surfaces far away from its cause. Every consumption edge between tiers must name its workspace explicitly, because state isolation means nothing propagates automatically.

One property cuts the other way, in your favour: the lock is per state object. Two workspaces in the same bucket lock different keys, so a colleague's apply in dev never queues behind your staging run. That concurrency is real, which means CI must serialise environments by intention — ordered needs: chains, manual gates — rather than expecting state locking to enforce order for you.

Notice also what isolation does not give you: production state is protected by the same bucket and the same credentials as staging, so a developer who can run terraform state pull in dev can usually read production's state too. That is the isolation workspaces never do, and it is the first argument you will meet for splitting environments into separate directories — the subject of Workspace Design Tradeoffs.

Quiz

1. With the S3 backend, a root config uses key = 'main/terraform.tfstate'. Where does the state for the workspace 'staging' end up?

Question 1 options

2. A teammate runs terraform workspace new prodction (a typo) and then terraform plan. What does the plan show?

Question 2 options

3. terraform workspace delete staging fails with a message about the workspace still tracking resources. What is the correct response?

Question 3 options

4. Which statement about terraform.workspace is correct?

Question 4 options

Flashcards

Question

Where is the current workspace name stored, and what are the two ways that bites you?

Answer

In .terraform/environment inside the ignored .terraform directory. It is per working directory rather than per shell, so switching in one terminal switches every other terminal on that directory — and a fresh clone plus terraform init silently returns you to the default workspace.

Question

How does the S3 backend lay out workspace state?

Answer

The default workspace is stored at exactly the configured key. Every other workspace is stored at <workspace_key_prefix>/<workspace_name>/<key>, with env: as the default prefix (changeable via workspace_key_prefix), e.g. env:/staging/main/terraform.tfstate.

Question

What does terraform.workspace evaluate to, and where is it illegal?

Answer

The current workspace's name as a string — 'default' when no workspace was selected — usable in any expression for conditionals and tags. It is illegal in the backend block, because backend arguments must be literals evaluated before the graph exists.

Question

What happens if you delete a workspace that still tracks resources?

Answer

The command is refused unless -force is passed, and even then only the state is discarded: the resources become dangling, physically present but no longer manageable by Terraform. Destroy first, delete second, and never force-delete a workspace whose state you have not read. The default workspace can never be deleted.

Question

What does TF_WORKSPACE do and when should it be used?

Answer

It selects the workspace for a run without an interactive prompt, overriding the stored selection — meant for pipelines. HashiCorp warns against it in local shells because a forgotten export silently applies changes to the wrong state.

Revision Notes

Key Takeaways

  • 1. Workspaces are equivalent to renaming your state file: same configuration, independent state, so plans in one workspace never see another's resources
  • 2. State location is backend-specific — terraform.tfstate.d/<name>/ locally, env:/<name>/<key> on S3 — while the current selection lives in .terraform/environment and is per directory
  • 3. terraform.workspace is a plain string for conditionals and tags; the backend block must stay literal and variable files are not workspace-aware
  • 4. Isolation covers state only: configuration, lock file, provider credentials, and state credentials are shared across every workspace
  • 5. The recurring failure modes — stale selection, typo'd workspace, forced delete, implicit remote-state reads — each have a cheap guard: assert, verify, destroy first, and name the workspace explicitly

Interview Tips

  • • 'What does a workspace actually change?' — the state file and nothing else: mention terraform.tfstate.d locally and the env: prefix on S3, then say configuration, lock file, and credentials stay shared
  • • 'How do you know which workspace you are in?' — terraform workspace show reads .terraform/environment; note it is per directory not per shell, and that TF_WORKSPACE overrides it in CI
  • • 'Can I use terraform.workspace in the backend key?' — no: backend arguments must be literals because the backend is configured before the graph exists; the backend appends the workspace prefix itself
  • • 'A developer deleted a workspace with -force. What is the fallout?' — dangling resources: real infrastructure with no state. Recovery is import or manual cleanup, and the prevention is destroy-then-delete plus keeping -force out of scripts

Cheat Sheet

Workspace Basics with the CLI

The five commands:

Command Behaviour
terraform workspace list Lists workspaces, * marks the current one, default always present
terraform workspace new NAME Creates and switches to NAME
terraform workspace select NAME Switches; fails unless the name exists (-or-create to create)
terraform workspace show Prints the current workspace name
terraform workspace delete NAME Refuses while tracking resources (-force overrides) and never deletes default

Where state lives:

local backend   terraform.tfstate.d/<name>/terraform.tfstate
S3 backend      default workspace → <key>
                other workspaces  → env:/<name>/<key>   (workspace_key_prefix)
current name    .terraform/environment   (per directory, shared by every terminal)

In configuration:

locals { env = { dev = {...}, production = {...} }[terraform.workspace] }

tags = { Environment = terraform.workspace }

lifecycle {
  precondition {
    condition     = terraform.workspace == var.expected_workspace
    error_message = "This root must run against the expected workspace."
  }
}

Automation: TF_WORKSPACE=staging terraform apply -input=false selects a workspace non-interactively (CI only — it is easy to leave set locally).

Remember: workspaces isolate state only; config, credentials, lock file, and blast radius stay shared.

Continue Learning