October 28, 2025josh-pollara

Terraform Multi-Environment Workflows with 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 Terrateam'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.

Why you might be managing Terraform in multiple environments

Common drivers:

  • Risk containment – validate changes in dev and staging before touching prod.
  • Isolation and compliance – separate state, credentials, and blast radius by environment or tenant.
  • Speed at scale – parallel plans and applications, fewer merge conflicts, and more precise ownership boundaries.

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

Two approaches to multi-environment Terraform management

Separate directories (or "stacks")

Structure:

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

Pros:

  • Clear isolation per env, with distinct state files and backends by default.
  • Easy to apply least-privilege access controls per path.
  • Aligns well with many GitOps tools and Terrateam's notion of repo/dir/workspace tuples.

Cons:

  • Code duplication occurs unless you factor modules well.
  • Keeping variables and providers consistent across folders needs discipline.

Terrateam'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.

ProsCons
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.

For broader context, see Multiple Environments in the Terrateam docs and our Multi-Environment Terraform Management with Terrateam blog.

How to structure Terraform code to handle environment differences

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

  • Variables with sane defaults.
  • Per-workspace *.tfvars files.
  • Backend key templating so each workspace gets its own state.

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:

  • Runs terraform fmt/init/validate and plan on pull requests.
  • Requires a manual env selection, or infers from branch, for apply.
  • Uses hashicorp/setup-terraform (the current, supported action).

Tip: For a deeper CI/CD walkthrough with OIDC auth and remote state, Terrateam'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. Terrateam's How to Use GitOps with Terraform: A Real World Guide emphasizes predictable, review-first workflows – plans in PRs, applies on merge.

Multi-environment Terraform management best practices

Always isolate the state per environment

Do this either via separate directories with distinct backends or via workspace-keyed state. Terrateam 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. Terrateam'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 Terrateam'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 Terrateam 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.

For additional background and alternatives, see Terrateam's comparison of Terraform Cloud vs. Terrateam (GitOps workflows) and their multi-environment management overview.

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.

Suppose you want these guardrails without bespoke CI glue. In that case, Terrateam provides a GitOps-native path that wires together runs, reviews, state isolation, and drift detection, using the same repo and pull requests you already have. Get started today and sign in to Terrateam.