Skip to content

Terraform Workspaces

A Terraform workspace lets one configuration produce several separate state files switched by name, but every workspace still runs through the exact same backend, the exact same provider configuration, and the exact same .tf code.

Instead of copy-pasting a whole directory per environment, workspaces let you keep a single set of .tf files and simply switch which named state file Terraform reads from and writes to:

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

terraform workspace new NAME creates a workspace and switches to it immediately. terraform workspace select NAME switches the active workspace without creating a new one. terraform workspace list shows every workspace that exists, with an asterisk marking the one currently active. The active workspace is tracked locally in the .terraform directory, and every plan or apply you run only ever touches that workspace’s own state — the dev state and the prod state are two entirely separate files, even though both come from the same main.tf.

Terraform exposes the active workspace name as a built-in value, terraform.workspace, which you can interpolate directly into resource names, labels, or a lookup map to change behavior per workspace without writing separate code for each one:

locals {
machine_types = {
dev = "e2-small"
staging = "e2-medium"
prod = "e2-standard-4"
}
}
resource "google_compute_instance" "app" {
name = "app-${terraform.workspace}"
machine_type = lookup(local.machine_types, terraform.workspace, "e2-small")
zone = "us-central1-a"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
}
}

Switch to the prod workspace and this same resource block names the instance app-prod and sizes it as e2-standard-4. Switch to dev and the identical code produces app-dev sized as e2-small. Nothing about the .tf file itself changes between environments — only the active workspace does.

Here is the part that is easy to miss: the backend block, the provider "google" block, and every other line of .tf code are shared, unchanged, across all workspaces.

terraform {
backend "gcs" {
bucket = "acme-terraform-state"
prefix = "app"
}
}
provider "google" {
project = "acme-shared-project"
region = "us-central1"
}

Terraform automatically namespaces each workspace’s state object inside that same bucket so dev, staging, and prod do not overwrite one another’s state file, but the bucket itself, the project in the provider "google" block, and the credentials Terraform authenticates with are identical no matter which workspace is active. There is no hard boundary here at all — nothing stops someone from being in the dev workspace, believing they are about to change a disposable test instance, when in fact every workspace shares the same acme-shared-project GCP project and the same real prod-adjacent resources it contains. A wrong terraform workspace select followed by an apply is a genuinely easy mistake to make, and workspaces alone give you no way to prevent it.

flowchart TD
  config["Single main.tf + backend + provider google.project = acme-shared-project"]
  config -->|terraform workspace select dev| devState["dev state file"]
  config -->|terraform workspace select staging| stagingState["staging state file"]
  config -->|terraform workspace select prod| prodState["prod state file"]
  devState -.->|same backend, same project| shared["acme-shared-project"]
  stagingState -.->|same backend, same project| shared
  prodState -.->|same backend, same project| shared
One Terraform configuration and backend, three workspaces, three state files, one shared GCP project

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 google_compute_instance (same terraform.workspace mechanism):

main.tf
locals {
machine_types = {
default = "e2-small"
dev = "e2-small"
staging = "e2-medium"
prod = "e2-standard-4"
}
}
resource "local_file" "app" {
filename = "${path.module}/app-${terraform.workspace}.txt"
content = "workspace=${terraform.workspace} machine_type=${lookup(local.machine_types, terraform.workspace, "e2-small")}\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:

$ cat app-default.txt
workspace=default machine_type=e2-small
$ cat app-staging.txt
workspace=staging machine_type=e2-medium

Real on-disk state layout:

$ 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; every other workspace gets a terraform.tfstate.d/ subdirectory.

Reading AI-generated code, and a real bug this course itself had

Section titled “Reading AI-generated code, and a real bug this course itself had”

A google_compute_instance resource with no network_interface block is not a stylistic omission — it is a real, provider-enforced error. Proof, running terraform validate against exactly the google_compute_instance block earlier in this lesson, before its fix:

$ terraform validate
Error: Insufficient network_interface blocks
At least 1 "network_interface" blocks are required.

The earlier version of this lesson’s own example had exactly this bug — a google_compute_instance with boot_disk but no network_interface, something AI-generated Compute Engine examples get wrong constantly, because the resource looks complete with an image and a machine type. The fix, confirmed with a real passing terraform validate afterward, is the network_interface { network = "default" } block now shown above.

🐛 Spot the bug

resource "google_compute_instance" "app" {
name = "app-${terraform.workspace}"
machine_type = "e2-small"
zone = "us-central1-a"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
}
Show the answer

Proven above with a real terraform validate run: this resource is missing a required network_interface block. google_compute_instance needs at least one, and the provider rejects the config at validate time with “Insufficient network_interface blocks” — not a plan-time or apply-time surprise, a validate-time one. This is exactly the bug this lesson’s own example originally had.

A workspace is a pointer, not a boundary — proven above, terraform.tfstate.d/NAME/ is the entire mechanism.

ChoiceBenefitCost
WorkspacesGenuinely DRY — one resource block, proven above to produce different values per workspaceZero isolation of backend/provider/project, proven above
  • “Each workspace can have its own GCP project.” Proven above: every workspace reads the exact same backend and provider configuration.
  • “A resource block that references machine_type and boot_disk correctly must be complete.” Proven above: a real, syntactically plausible google_compute_instance block still failed real terraform validate for a missing network_interface block — completeness has to be checked against the provider schema, not eyeballed.
What does a Terraform workspace actually give you a separate copy of
What is terraform.workspace typically used for
What is the specific real-world risk that Terraform workspaces introduce
Which of these stays identical across every workspace in a single configuration
A google_compute_instance resource has boot_disk, machine_type, and name set, but no network_interface block. What does real terraform validate show, proven above?
Where does the default workspace state file real on-disk path, as proven above, differ from every other workspace?