RSS

Terraform Multi-Environment Workflows with Workspaces

Terraform GitOps CI/CD Github Actions Workspaces

What you'll learn: This hands-on guide compares the two ways to run Terraform multi-environment workflows, dev/staging/prod or multi-tenant, from one repo – workspaces vs. separate directories/stacks. It then walks you through a clean GitHub Actions pipeline that selects and applies to the correct workspace with isolated state per environment.

When you grow from a single sandbox to multiple environments, Terraform workflows can get messy – branch naming conventions drift, state files collide, and a "quick fix" in staging finds its way into prod.

In this tutorial, you'll learn two proven patterns to manage Terraform multi-environment deployments from a single repository. We'll compare Terraform workspaces to separate directories/stacks, and then implement a pragmatic, production-ready GitHub Actions workflow that plans on pull requests and applies on merges or manual approvals – always in the right environment.

For broader background and patterns, see Stategraph's Building a CI/CD Pipeline for Terraform with GitHub Actions and How to Use GitOps with Terraform: A Real-World Guide, which I reference throughout this blog.

What are Terraform workspaces?

Terraform workspaces are Terraform's built-in way to keep more than one state file for the same Terraform configuration, which is why they show up so often once you start thinking about multiple deployments from one repo. Each working directory has exactly one backend (a backend that stores persistent data like Terraform state), and that persistent data belongs to a workspace, which means one workspace maps to one state file, and multiple workspaces mean you maintain separate state files while keeping the same configuration, the same root module, and the same module blocks.

Terraform starts you in a default workspace, which is literally named default. You can't delete the workspace named default, and if you've never created a new workspace, then you're already using the default workspace (even if you never typed terraform workspace in your life).

The part that trips people up is what workspaces isolate, and what they don't. Workspaces isolate state, which is valuable because state collisions are how "same infrastructure, different environments" turns into "all the resources, one accidental overwrite". But workspaces are not a suitable isolation mechanism for system decomposition, or for cases where different internal teams need hard separation through separate credentials, distinct access controls, and different blast-radius boundaries, because a workspace is not its own separate configuration, and it does not magically give you a security boundary on its own.

There's also a naming collision in the ecosystem.

Terraform CLI workspaces (the ones you manage with terraform workspace commands) are different from workspaces in HCP Terraform and Terraform Enterprise, where "workspace" is an object in the remote platform with runs, variables, and policy controls. The overlap is real (both end up pointing at a state file), but the operational model is different enough that it's worth calling out before you build muscle memory in the wrong place.

Why you might be managing Terraform in multiple environments

Common drivers:

Stategraph routinely sees teams succeed by coupling GitHub Actions with strict state isolation and environment-aware workflows.

When to use Terraform workspaces for this task

Terraform workspaces shine when you want multiple instances of the same infrastructure from the same Terraform code, where the differences between environments are mostly inputs, not architecture.

That can be either one of the following:

Workspaces are also a pragmatic fit when your remote backend already supports multiple workspaces and you're comfortable with its behavior. With the S3 backend, for example, Terraform stores the state for the default workspace at the configured key, and it stores other workspaces under a predictable prefix that includes the workspace name, which is exactly what you want when the goal is to maintain separate state files without inventing a new backend configuration per environment.

Where they stop being a good fit is where people try to make them do organizational work. If different environments require different credentials, different access controls, different approval rules, or simply a different ownership model across internal teams, then directories or stacks (or separate repos) tend to be the more honest shape, because the separation is visible in version control systems and easier to enforce with policy and least privilege.

Workspaces are not appropriate for system decomposition or deployments requiring separate credentials and access controls, which is another way of saying that workspaces can help you manage multiple environments, but they're not a substitute for a real security boundary.

If you've ever watched a pipeline apply to the wrong workspace, you already know why this matters. A workspace is a state selector, not a guardian.

How to use Terraform workspaces

Using Terraform workspaces is mostly about being explicit, because the UX is simple and the consequences of ambiguity are not.

You typically start by asking Terraform what already exists, because existing workspaces are the reality you're about to mutate, and terraform workspace list (or the terraform workspace list command if you like saying the whole thing) tells you what Terraform can see in the current backend.

From there, you either create a new workspace or select one, and those map directly to the two commands you'll use constantly: terraform workspace new and terraform workspace select.

In a local loop, the workflow is almost boring. You run terraform workspace commands, you create a new workspace for dev with terraform workspace new dev (people also write this as workspace new dev, because muscle memory is a thing), and then you switch with terraform workspace select dev (or terraform workspace select command, or terraform workspace select, all the same intent).

At that point, the current workspace (meaning the currently selected workspace) becomes the lens through which Terraform reads and writes Terraform state, so terraform plan and apply operate only on the state file for that workspace, even though the resources still physically exist in your cloud account for other workspaces. This is why switching to the wrong workspace is such a classic footgun. Terraform is doing exactly what you asked, you just asked it while thinking about a different environment.

In CI, you want the same mechanics, but with less trust. Don't rely on the default workspace in automation, even if the default workspace feels like a safe baseline, because "safe baseline" is how prod gets touched during a late-night refactor.

The safe pattern is to run terraform workspace list, select workspace explicitly, and only then run terraform plan and terraform apply, with the workspace name derived from an input, a protected branch convention, or an environment variable that is set by the workflow runtime.

Terraform supports an environment variable called TF_WORKSPACE, and yes, it works, but it's also invisible unless you log it, which is why many teams prefer to run terraform workspace select and echo the workspace name right in the job output so reviewers can't miss it.

Two approaches to multi-environment Terraform management

Separate directories (or "stacks")

Structure:

infra/
  dev/
    main.tf
    variables.tf
    backend.tf
  staging/
    ...
  prod/
    ...

Pros:

Cons:

Stategraph's code organization blog recommends state file isolation as a default, warning that shared backends plus workspaces can introduce production risk if misused. The folder-per-env layout makes isolation explicit.

One directory + Terraform workspaces

Concept

You reuse the same configuration but switch the active workspace (e.g., dev, staging, prod) to select a different state file and variables. Terraform CLI supports the creation, listing, and selection of workspaces when backed by a remote backend; each workspace maps to a separate state.

Pros Cons
Minimal duplication and the simplest local developer experience. Fast to add new environments (just create a new workspace). Footgun risk if your CI accidentally runs the wrong workspace against shared backends. Harder to enforce least-privilege if everything sits in one directory.

How to structure Terraform code to handle environment differences

We'll keep one directory (infra/) and parameterize environment differences via:

Example: Variables and backend

variables.tf

variable "environment" {
  description = "The target environment (dev|staging|prod)"
  type        = string
}

variable "vpc_cidr" {
  description = "CIDR for the VPC"
  type        = string
}

variable "tags" {
  description = "Common resource tags"
  type        = map(string)
  default     = {}
}

backend.tf (S3 backend example):

terraform {
  backend "s3" {
    bucket = "mycompany-tf-state"
    region = "us-east-1"
    # key must vary per workspace to isolate state
    key    = "networking/${terraform.workspace}/terraform.tfstate"
    encrypt = true
  }
}

The ${terraform.workspace} interpolation ensures separate state files per environment even though we reuse one directory. That aligns with Terraform's workspace semantics and avoids state collisions.

Example: Per-environment tfvars files

dev.tfvars

environment = "dev"
vpc_cidr    = "10.10.0.0/16"
tags = {
  "env" = "dev"
  "app" = "payments"
}

staging.tfvars

environment = "staging"
vpc_cidr    = "10.20.0.0/16"
tags = {
  "env" = "staging"
  "app" = "payments"
}

prod.tfvars

environment = "prod"
vpc_cidr    = "10.30.0.0/16"
tags = {
  "env" = "prod"
  "app" = "payments"
}

A production-ready GitHub Actions workflow that targets the right workspace

We'll build a workflow that:

Tip: For a deeper CI/CD walkthrough with OIDC auth and remote state, Stategraph's GitHub Actions guide is a helpful reference.

Option A: Manual input to select the environment

.github/workflows/terraform.yml

name: Terraform

on:
  pull_request:
    paths:
      - 'infra/**'
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment (dev|staging|prod)'
        required: true
        default: 'dev'

permissions:
  contents: read
  id-token: write  # if using cloud OIDC
  pull-requests: write

jobs:
  plan:
    name: Plan
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.8.5

      - name: Terraform Init
        run: terraform init -input=false

      - name: Select PR workspace (from branch or label)
        if: github.event_name == 'pull_request'
        run: |
          # default to 'dev' for PR plans, override via labels if desired
          TF_WS=dev
          echo "Using workspace: $TF_WS"
          terraform workspace new "$TF_WS" || terraform workspace select "$TF_WS"

      - name: Terraform Plan (PR)
        if: github.event_name == 'pull_request'
        run: terraform plan -input=false -var-file="dev.tfvars"

  apply:
    name: Apply
    needs: plan
    runs-on: ubuntu-latest
    if: github.event_name == 'workflow_dispatch'
    defaults:
      run:
        working-directory: infra
    steps:
      - uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.8.5

      - name: Terraform Init
        run: terraform init -input=false

      - name: Select workspace (manual)
        run: |
          TF_WS= "${{ github.event.inputs.environment }}"
          echo "Applying to workspace: $TF_WS"
          terraform workspace new "$TF_WS" || terraform workspace select "$TF_WS"

      - name: Terraform Apply
        run: terraform apply -input=false -auto-approve -var-file="${{ github.event.inputs.environment }}.tfvars"

Why this works: You isolate state via backend key templating and you force an explicit workspace selection on both PRs and applies, removing ambiguity. Using the maintained setup-terraform action avoids the deprecated terraform-github-actions wrapper.

Option B: Infer environment from branch naming

If you prefer automation over manual inputs, you can parse branch names in PRs and restrict applies to main merges:

      - name: Derive env from branch
        if: github.event_name == 'pull_request'
        run: |
          BR="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
          case "$BR" in
            *prod*) TF_WS=prod ;;
            *staging*) TF_WS=staging ;;
            *) TF_WS=dev ;;
          esac
          echo "Selecting workspace: $TF_WS"
          terraform workspace new "$TF_WS" || terraform workspace select "$TF_WS"

      - name: Plan with derived tfvars
        if: github.event_name == 'pull_request'
        run: terraform plan -input=false -var-file="${TF_WS}.tfvars"

Set up guardrails – even with branch inference, keep applies gated to merges or manual approvals. Stategraph's How to Use GitOps with Terraform: A Real World Guide emphasizes predictable, review-first workflows – plans in PRs, applies on merge.

Multi-environment management and Terraform workspace best practices

Always isolate the state per environment

Do this either via separate directories with distinct backends or via workspace-keyed state. Stategraph recommends isolation as a default to set clear security boundaries and reduce blast radius.

Space selection in CI

Never rely on Terraform's default workspace in automation. Initialize, then explicitly select the intended workspace before running plan/apply. Use the CLI as it supports robust workspace management.

Use maintained GitHub Actions for Terraform

Adopt hashicorp/setup-terraform, not the deprecated terraform-github-actions.

Short-lived credentials and OIDC

Move away from long-lived cloud keys. Stategraph's CI/CD guide shows how to set up secure OIDC auth from GitHub Actions to your cloud account.

Plan on PR, apply on merge (or manual)

This is the heart of Terraform multi-environment GitOps: show the diff where reviewers live and only apply after approval/merge.

Add drift detection

Schedule terraform plan against each workspace (read-only creds) to detect drift and alert proactively. See Stategraph's Implementing Terraform drift detection with GitHub Actions blog.

Scale with modules and (optionally) stacks

Keep environment directories or workspace-driven configs thin by extracting reusable modules. As complexity grows, Terrateam Stacks offer orchestration across layered environments with declarative rules.

Document your environment contract

Codify which variables differ across environments and why. Store *.tfvars and a short README in the repo.

Pin providers and Terraform versions

Control upgrades deliberately. Use setup-terraform to support pinning versions in CI for reproducibility.

Avoid mixing environment configs in one run.

Each CI job should target one workspace and one backend key. If you need to fan out, run independent jobs per environment to avoid accidental cross-contamination.

An end-to-end example: one repo, three environments, safe by default

Prefer explicit work

Repository layout:

infra/
  backend.tf        # S3 backend; key uses ${terraform.workspace}
  main.tf           # calls modules/ and providers
  variables.tf
  dev.tfvars
  staging.tfvars
  prod.tfvars
modules/
  vpc/
  compute/
.github/
  workflows/
    terraform.yml

The typical flow:

  1. Engineer opens a PR from feature/add-endpoints → GitHub Actions runs plan in dev workspace (derived or defaulted), comments the plan in the PR.
  2. Reviewer approves the merge to main, workflow applies to a selected workspace such as staging via manual dispatch, prod via a separate release workflow).
  3. Drift job runs nightly for dev, staging, and prod to alert you about out-of-band changes.

This delivers the benefits of Terraform multi-environment without the common pitfalls, such as wrong-env applies, state collisions, and leaky credentials.

A trade-offs recap: when to pick each option

HashiCorp's docs and Stategraph guidance cover both models, but here's the choice to make:

Choose separate directories/stacks if… Choose workspaces if…
Your security model demands strict path-level controls. Teams own different environments. You're already orchestrating many components (Terrateam Stacks can help). You want minimal duplication and your team can enforce explicit workspace selection, state isolation, and guarded applies. Complexity grows, use folder-per-env.

Terragrunt vs. Terraform workspaces

You may have heard of Terragrunt as another alternative solution because it orbits the same problem space as Terraform workspaces, but the tools actually live at different layers.

Terraform workspaces are a state selection feature inside Terraform, which means they help you manage multiple deployments of the same configuration by creating and selecting different state files, and they're best when you want multiple environments that are still recognizably the same infrastructure.

Terragrunt is a wrapper that's trying to solve the mess that shows up when you don't have one root module and three workspaces; you have many roots, many modules, and enough variation that "same configuration" becomes fiction.

It gives you a way to keep per-environment configuration DRY while still having your own separate configuration per unit of change, which is why it shows up when teams move into larger systems, cross-account AWS layouts, and more serious system decomposition, where different workspaces would not be a suitable isolation mechanism anyway.

That's also the point where different internal teams start to care about control access as a first-class requirement, not a footnote, and a directory structure that matches ownership becomes more important than a clever workspace name.

There's also a practical operational difference:

If you're already on HCP Terraform or Terraform Enterprise, it helps to name the distinction out loud. The platform's workspace object can give you policy, run history, variable sets, and access controls, while Terraform CLI workspaces give you multiple states behind one backend.

They can complement each other, but they're not interchangeable, and the fastest way to get confused is to assume the word means the same thing everywhere.

Conclusion

You've seen two viable approaches to Terraform multi-environment management and implemented a concrete GitHub Actions workflow that's explicit about which workspace it targets. The key is isolation + intent: separate state per environment, select the workspace deliberately, plan on PRs, and only apply after approval.