Skip to content

Terraform Workspaces

A Terraform workspace lets one configuration keep several separate state files, each under its own name, which is a cheap way to model dev, staging, and prod, but a workspace changes nothing about the backend, the provider, or the Azure subscription that configuration actually talks to.

terraform workspace is a built-in command family for managing named state inside a single working directory. You create one workspace per environment, and Terraform keeps a completely separate state file for each:

Terminal window
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace list
# dev
# staging
# * prod
terraform workspace select dev

The active workspace is just a pointer: whichever one is selected determines which state file the next plan or apply reads and writes. With the default local backend, Terraform stores this under terraform.tfstate.d, one subdirectory per workspace:

Terminal window
tree terraform.tfstate.d
terraform.tfstate.d
├── dev
│ └── terraform.tfstate
├── staging
│ └── terraform.tfstate
└── prod
└── terraform.tfstate

With a remote backend such as azurerm, the same idea applies — Terraform namespaces the state blob per workspace instead of per local directory — but the effect is identical: dev cannot see or overwrite prod’s resources, because they live in entirely separate state.

Terraform exposes the name of the currently selected workspace as a built-in value, terraform.workspace, which you can interpolate anywhere a string is expected. The most common use is picking a size or a name per environment out of a lookup map:

locals {
vm_size = {
dev = "Standard_B1s"
staging = "Standard_B2s"
prod = "Standard_D2s_v5"
}
}
resource "azurerm_linux_virtual_machine" "app" {
name = "app-${terraform.workspace}"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
size = local.vm_size[terraform.workspace]
admin_username = "azureuser"
# ...
}

Select dev and the VM is named app-dev and sized Standard_B1s. Select prod and the same .tf file produces app-prod sized Standard_D2s_v5, with no code duplicated anywhere. This is the appeal of workspaces: one set of .tf files, one place to fix a bug, and per-environment variation driven entirely by which workspace happens to be active.

Here is the part that is easy to miss. terraform workspace new only ever creates a new state file. It does not create a new backend configuration, a new provider configuration, or a new Azure subscription. All of that lives in the same .tf code, shared verbatim across every workspace:

terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstateacct001"
container_name = "tfstate"
key = "app.terraform.tfstate"
}
}
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}

Every workspace — dev, staging, and prod alike — reads and writes through that one backend block and authenticates through that one provider block, against that one subscription_id. There is no workspace-scoped credential, no workspace-scoped subscription, and no workspace-scoped copy of the .tf code. The only thing that differs between workspaces is which state file gets touched and whatever you chose to key off terraform.workspace.

That makes workspaces a poor fit for environments that need genuinely separate Azure subscriptions, regions, or credentials, and it creates a specific, real risk: nothing in the tooling stops someone from being on the prod workspace while believing they are still on dev, and applying a change sized and scoped for a toy environment directly against production, because the backend, the provider, and the subscription behind the scenes never changed at all.

flowchart LR
  config["One Terraform config, one backend block"] -->|terraform workspace new dev| devWs["dev workspace"]
  config -->|terraform workspace new staging| stagingWs["staging workspace"]
  config -->|terraform workspace new prod| prodWs["prod workspace"]
  devWs --> devState["dev state file"]
  stagingWs --> stagingState["staging state file"]
  prodWs --> prodState["prod state file"]
  devWs -.-> shared["Same subscription_id, same backend, same .tf code"]
  stagingWs -.-> shared
  prodWs -.-> shared
One Terraform config and one backend, three workspaces, three state files, but everything else shared including the Azure subscription

Proof: real state paths, terraform.tfstate.d/, and a real value flip

Section titled “Proof: real state paths, terraform.tfstate.d/, and a real value flip”

Provable offline with local_file standing in for azurerm_linux_virtual_machine (same terraform.workspace mechanism):

main.tf
locals {
vm_size = {
default = "Standard_B1s"
dev = "Standard_B1s"
staging = "Standard_B2s"
prod = "Standard_D2s_v3"
}
}
resource "local_file" "app" {
filename = "${path.module}/app-${terraform.workspace}.txt"
content = "workspace=${terraform.workspace} size=${local.vm_size[terraform.workspace]}\n"
}

Real apply in default, then terraform workspace new staging and apply again with the same config:

$ terraform apply -auto-approve # default workspace
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace new staging
$ terraform apply -auto-approve # staging workspace, same .tf files
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

The two generated files prove terraform.workspace flipped per workspace, from the identical resource block:

$ cat app-default.txt
workspace=default size=Standard_B1s
$ cat app-staging.txt
workspace=staging size=Standard_B2s

Real on-disk state layout — the concrete shape terraform.tfstate.d/WORKSPACE/ takes:

$ find . -iname "*.tfstate*" -not -path "./.terraform/*"
./terraform.tfstate
./terraform.tfstate.d
./terraform.tfstate.d/staging/terraform.tfstate

default is the one special case — its state lives at the plain terraform.tfstate path, exactly where state would live if workspaces were never used; every other workspace gets a terraform.tfstate.d/ subdirectory. terraform workspace list confirms both exist:

$ terraform workspace list
default
* staging
  • A local.vm_size lookup map missing a key for a workspace actually in use — terraform.workspace evaluates to whatever string is active, and indexing a missing key is a real error, not a silent fallback.
  • Any CI script running terraform apply without first checking terraform workspace show — workspace selection is process-local, persistent state with no connection to which branch or environment CI believes it is deploying.

🐛 Spot the bug

locals {
vm_size = {
dev = "Standard_B1s"
staging = "Standard_B2s"
}
}
resource "azurerm_linux_virtual_machine" "app" {
size = local.vm_size[terraform.workspace]
}
Show the answer

The lookup map only has dev and staging keys. Proven above, terraform.workspace evaluates to whatever workspace is actually active — the moment someone creates and switches to a prod workspace, local.vm_size[terraform.workspace] fails with a real “key does not exist” error at plan time.

A workspace is a pointer, not a boundary — proven above, terraform.tfstate.d/NAME/ is the entire mechanism, a subdirectory Terraform picks based on one string, read against one unchanging set of .tf files and one unchanging backend/provider/subscription_id.

ChoiceBenefitCost
WorkspacesGenuinely DRY — one resource block, proven above to produce different values per workspaceZero isolation of backend/provider/subscription_id, proven above; the only thing separating environments is which name is active
  • “Each workspace can have its own subscription_id or backend bucket.” Proven above: every workspace reads the exact same backend and provider configuration.
  • “The default workspace is stored the same way as every other workspace.” Proven above with a real find: default’s state has no terraform.tfstate.d/ subdirectory at all.
What does creating a new Terraform workspace actually isolate from other workspaces
Which of these is shared identically across every workspace in the same configuration
What is terraform.workspace typically used for
What is the specific real-world risk that Terraform workspaces introduce
Where does the default workspace state file real on-disk path, as proven above, differ from every other workspace?
A local.vm_size map only defines dev and staging keys, but someone creates and switches to a prod workspace using this same config. What happens, proven above?