Terraform Multi-Environment Workflows with Workspaces
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.
| 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. |
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:
- Engineer opens a PR from feature/add-endpoints → GitHub Actions runs plan in dev workspace (derived or defaulted), comments the plan in the PR.
- Reviewer approves the merge to main, workflow applies to a selected workspace such as staging via manual dispatch, prod via a separate release workflow).
- 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.