Terraform Workspaces
The idea in one sentence
Section titled “The idea in one sentence”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.
One configuration, many state files
Section titled “One configuration, many state files”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:
terraform workspace new devterraform workspace new stagingterraform workspace new prod
terraform workspace list# default# * dev# staging# prod
terraform workspace select stagingterraform applyterraform 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.
Varying behavior with terraform.workspace
Section titled “Varying behavior with terraform.workspace”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.
What workspaces do not isolate
Section titled “What workspaces do not isolate”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
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):
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 workspaceApply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace new staging$ terraform apply -auto-approve # staging workspace, same .tf filesApply complete! Resources: 1 added, 0 changed, 0 destroyed.The two generated files prove terraform.workspace flipped per workspace:
$ cat app-default.txtworkspace=default machine_type=e2-small$ cat app-staging.txtworkspace=staging machine_type=e2-mediumReal on-disk state layout:
$ find . -iname "*.tfstate*" -not -path "./.terraform/*"./terraform.tfstate./terraform.tfstate.d./terraform.tfstate.d/staging/terraform.tfstatedefault 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 validateError: Insufficient network_interface blocksAt 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.
Mental Model
Section titled “Mental Model”A workspace is a pointer, not a boundary — proven above, terraform.tfstate.d/NAME/ is the entire mechanism.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Workspaces | Genuinely DRY — one resource block, proven above to produce different values per workspace | Zero isolation of backend/provider/project, proven above |
Common Misconceptions
Section titled “Common Misconceptions”- “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_instanceblock still failed realterraform validatefor a missingnetwork_interfaceblock — completeness has to be checked against the provider schema, not eyeballed.