Managing Environments in Terraform
Run the same infrastructure across dev, staging, and prod without gluing them together. CLI workspaces vs directory-per-environment, their honest tradeoffs, and a layout that scales.
Every real Terraform setup hits the same wall: you have written the configuration once, and now you need to run it three or four times - dev, staging, prod, maybe a per-developer sandbox. Each copy needs its own state, its own sizing, its own credentials, and its own blast radius, but the actual resources are the same. Get this wrong and you get the two classic disasters: one giant state file where a terraform apply meant for staging tears down production, or four copy-pasted directories that drift apart until "dev" and "prod" are secretly different infrastructure. Terraform gives you two ways to solve this - CLI workspaces and directory-per-environment - and the honest truth is that one of them is far more common in production. This guide covers both, where each fits, and a layout you can actually grow into.
The problem, stated plainly
The thing you are trying to isolate is state. Terraform tracks what it has created in a state file, and that file is per-environment: your staging state should never know about your prod resources, and vice versa. If dev and prod share one state, then a plan or apply run against one can see, modify, and destroy the other. That is the failure mode you are designing against.
Beyond isolated state, a good environment setup gives you three more things:
- Per-environment values - prod runs bigger instances, more replicas, a real domain; dev runs the cheapest thing that works.
- Per-environment backends and credentials - ideally prod state lives in a prod account with prod-only access, so a mistake in dev physically cannot reach prod.
- Shared, identical configuration - the resource definitions themselves stay the same across environments, so what you test in staging is genuinely what ships to prod.
Both approaches below isolate state. Where they differ - sharply - is on the other three. Keep this list in mind as you read; it is the scorecard.
Approach 1: CLI workspaces
Terraform has a built-in feature called workspaces. Every configuration starts in a workspace named default, and you can create more. Each workspace gets its own separate state file inside the same backend, so switching workspaces switches which state you are operating on - without touching the configuration.
terraform workspace list # show workspaces (* marks the current one)
terraform workspace new staging # create and switch to "staging"
terraform workspace new prod # create and switch to "prod"
terraform workspace select staging # switch to an existing workspace
terraform workspace show # print the current workspace name
Inside your configuration, the current workspace name is available as terraform.workspace. The usual pattern is to key a lookup map off it so each environment gets different values from one config:
locals {
instance_type = {
default = "t3.micro"
staging = "t3.small"
prod = "t3.large"
}
replicas = {
default = 1
staging = 2
prod = 5
}
}
resource "aws_instance" "app" {
instance_type = local.instance_type[terraform.workspace]
# ...
tags = {
Name = "app-${terraform.workspace}"
Environment = terraform.workspace
}
}
Now terraform workspace select prod && terraform apply runs the same code against prod's state with prod's sizing. It is quick to set up, needs zero extra directories, and the whole thing lives in one place.
The honest limits of workspaces
Workspaces solve state isolation and nothing else, and the things they leave unsolved are exactly the things that bite in production:
- Same backend for every workspace. All workspaces share the one backend block, so every environment's state sits in the same bucket and (usually) the same cloud account. You cannot easily put prod state in a locked-down prod account and dev state somewhere loose. The strong isolation boundary you want between prod and everything else is not there.
- Same configuration and same credentials. There is one set of
.tffiles and one provider configuration. Whatever credentials your shell has when you runapplyare the credentials it uses, regardless of workspace. Nothing stops a prod apply from running with dev-scoped intentions or the other way around. - Easy to apply to the wrong environment. The only thing separating a harmless dev apply from a production change is which workspace happens to be selected in your terminal.
terraform workspace showis your only guardrail, and it is one you have to remember to check. People forget. This is the single biggest reason teams move away from workspaces for real environments. - The
terraform.workspaceconditionals get ugly. As environments diverge, the lookup maps andterraform.workspace == "prod" ? ... : ...ternaries spread through the code. Your configuration becomes a tangle of per-environment branching instead of a clean description of infrastructure.
Workspaces are genuinely useful for short-lived, throwaway, structurally-identical copies - spinning up a temporary test environment, a per-feature-branch stack, or a per-developer sandbox that shares the same account and lifecycle. For long-lived dev/staging/prod environments that must be isolated and will drift in their needs, they are the wrong tool.
Approach 2: directory (or root module) per environment
The pattern most production teams settle on is one root module per environment. Each environment is its own directory with its own backend configuration, its own variable values, and its own terraform init/plan/apply lifecycle. The shared logic lives in a reusable module that every environment calls - so the resources stay identical while the wiring around them is explicitly separated.
The key mental shift: instead of one config that behaves differently based on a selected workspace, you have several thin root configs that all instantiate the same module with different inputs. The differences between environments become plain, visible .tfvars and backend files rather than conditionals buried in the code.
A recommended layout
terraform/
modules/
app/ # the reusable module - the actual resources
main.tf
variables.tf
outputs.tf
environments/
dev/
main.tf # calls ../../modules/app
backend.tf # dev backend (dev bucket/account)
terraform.tfvars # dev values
staging/
main.tf
backend.tf
terraform.tfvars
prod/
main.tf
backend.tf
terraform.tfvars
The module under modules/app/ defines the infrastructure once. If you have not built a module before, see the Terraform modules guide - environments-per-directory is the payoff that makes writing modules worth it.
Each environment's main.tf is thin. It configures the provider and calls the shared module with that environment's inputs:
# environments/prod/main.tf
provider "aws" {
region = "us-east-1"
}
module "app" {
source = "../../modules/app"
environment = var.environment
instance_type = var.instance_type
replicas = var.replicas
}
The per-environment values live in that environment's terraform.tfvars, so the differences are explicit and reviewable:
# environments/dev/terraform.tfvars
environment = "dev"
instance_type = "t3.micro"
replicas = 1
# environments/prod/terraform.tfvars
environment = "prod"
instance_type = "t3.large"
replicas = 5
And each environment gets its own backend, which is the whole point - prod state can live in a prod-only bucket and account, physically separate from dev:
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "app/terraform.tfstate"
region = "us-east-1"
}
}
For more on backends, locking, and keeping state safe, see the remote state guide.
How you run it
Because each environment is its own directory, you operate on exactly one at a time by being in it. There is no hidden selected-workspace state to forget:
cd terraform/environments/dev
terraform init # inits against the DEV backend
terraform plan # plans DEV only, from dev/terraform.tfvars
terraform apply
cd ../prod
terraform init # a fully separate init against the PROD backend
terraform plan # prod, and only prod, can be touched here
terraform apply
To change production, you have to be in environments/prod/. Your working directory is the guardrail, and it is a much harder one to get wrong than a workspace name. It also means your CI pipeline can grant prod credentials only to the job that runs in the prod directory, closing the loop on real isolation.
The cost is a little repetition: each environment has its own main.tf, backend.tf, and terraform.tfvars. That repetition is a feature - it is what keeps environments independent and readable - but it does mean discipline to keep the root modules in sync as the shared module's interface changes. Teams that want to cut the boilerplate reach for a tool like Terragrunt, which templates the backend and provider blocks so each environment folder shrinks to just its inputs; that is a reasonable next step once this pattern is in place, but it is not required to start.
Choosing between them
Put the two side by side against the scorecard from the top:
- State isolation - both do this. This is where workspaces stop and directories keep going.
- Separate backends / accounts / credentials per environment - directories: yes, naturally. Workspaces: no, everything shares one backend.
- Blast-radius safety - directories: strong; you must be in the prod folder, and CI can scope prod credentials to it. Workspaces: weak; only the selected workspace name protects you, and it is easy to forget.
- Per-environment values - directories: explicit
.tfvarsfiles. Workspaces:terraform.workspacelookups and conditionals that grow messy as environments diverge. - Setup effort - workspaces: minimal, one config. Directories: a bit more scaffolding up front.
The recommendation is straightforward. Use directory-per-environment for anything long-lived and important - your real dev, staging, and prod. The extra structure buys you exactly the isolation and safety that matter most when the environment in question is production. Reach for workspaces only for ephemeral, structurally-identical copies within a single account - a temporary test stack, a per-branch preview, a personal sandbox - where the whole point is that they are throwaway and cheap to spin up.
The trap to avoid is using CLI workspaces as your dev/staging/prod boundary because it looked easiest on day one, then discovering months later that the only thing standing between a routine change and a production outage is which word terraform workspace show happens to print. Separate the directories, separate the backends, share the module - and the same infrastructure runs safely in every environment.