Terraform Workspaces vs Directories: Choosing an Environment Strategy

The Question Behind the Question

Teams rarely struggle to create a dev workspace. They struggle to decide how much isolation production requires. The environment pattern controls state blast radius, credentials, approvals, provider versions, and whether a developer can accidentally plan against production.

Terraform CLI workspaces provide multiple state instances for one configuration and backend. They are useful, but they are not a general security boundary. HCP Terraform workspaces are a different product concept with their own execution, variables, permissions, and state.

---

Pattern 1: CLI Workspaces

terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select dev
terraform workspace show

Configuration can derive settings from the current workspace:

locals {
  environments = {
    dev     = { instance_size = "small", replica_count = 1 }
    staging = { instance_size = "medium", replica_count = 2 }
    prod    = { instance_size = "large", replica_count = 4 }
  }
  env = local.environments[terraform.workspace]
}

This is compact and reduces duplication. It works well for short-lived preview stacks, training sandboxes, and multiple copies of the same application under the same trust boundary.

The risks are operational. The selected workspace is local CLI context, so a command run from the right directory can still target the wrong state. All environments share the same backend configuration and usually the same provider wiring. Conditional expressions accumulate as environments diverge. A production plan may be only one workspace select away from a development session.

Do not use CLI workspaces for environments requiring separate credentials, access controls, or administrative boundaries. Practice their mechanics in the workspace environments lab, but distinguish convenience from isolation.

---

Pattern 2: Directory Per Environment

infrastructure/
|-- modules/
|   |-- service/
|   `-- database/
|-- environments/
    |-- dev/
    |   |-- backend.tf
    |   |-- main.tf
    |   `-- terraform.tfvars
    |-- staging/
    `-- prod/

Each environment is a root module calling shared child modules:

module "checkout" {
  source = "../../modules/service"

  environment    = "prod"
  instance_size  = "large"
  replica_count  = 4
  deletion_guard = true
}

Each directory can have a different backend key, credential role, provider constraints, and CI approval policy. A production job can be restricted to environments/prod, while pull requests still validate all roots.

The cost is repetition. Provider and module call changes may need updates in several directories. Reduce that cost with small root modules, shared child modules, automated formatting and validation, and dependency tooling if the estate becomes large. Do not hide every difference behind a giant wrapper; visible root configuration is often valuable review context.

---

Pattern 3: Separate Repositories or Stacks

Some boundaries deserve complete separation: regulated production, different business units, or infrastructure with independent release cadences. Separate repositories can provide distinct ownership, secrets, branch protection, and audit trails.

The tradeoff is coordination. Shared module releases must be versioned. Cross-stack dependencies need narrow, stable interfaces, such as published DNS names or selected remote-state outputs. A monorepo can still contain separate roots and pipelines; repository count is not the only isolation mechanism.

---

Compare the Patterns

ConcernCLI workspacesEnvironment directoriesSeparate roots/repos
State isolationSeparate state namesSeparate backend configsFully separate
Credential isolationAwkwardStraightforwardStrongest
Configuration reuseHighestShared modulesVersioned modules
Environment differencesConditionals/mapsExplicit per rootExplicit per stack
Wrong-target riskHigherLowerLowest with policy
Best fitEphemeral copiesMost dev/stage/prod teamsHard boundaries

---

A Safe CI/CD Layout

For directory-based environments, derive the target from changed paths, never from an untrusted free-form workspace string. A production pipeline should:

  1. Authenticate with a short-lived production role.
  2. Run terraform init -reconfigure in the production root.
  3. Run fmt -check, validate, and policy/security checks.
  4. Create terraform plan -out=tfplan.
  5. Publish the human plan and machine-readable plan for review.
  6. Require approval, then apply that exact saved plan.
  7. Serialize applies for that state and retain logs.

Never generate one plan and then apply after silently changing variables, provider credentials, or backend context. Saved plans capture important context and may contain sensitive values, so protect them like state.

Recommendation

For most long-lived dev, staging, and production environments outside HCP Terraform, use small directory-based roots that call shared modules. Use CLI workspaces when the copies are structurally identical and share a trust boundary. Split roots further when state becomes too large or ownership and release cadence differ.

The Terraform theory course builds the mental model; the cross-state outputs lab lets you practice designing a narrow interface between isolated roots.