Introduction
When you move from a single Terraform configuration to managing multiple environments—dev, staging, production—you'll face a critical decision: do you use Terraform workspaces or organize your code into separate directories? Both approaches work, but they carry different trade-offs in isolation, maintainability, and operational safety. This guide walks you through the decision-making process and shows you practical patterns for each approach so your team can choose the method that fits your infrastructure needs.
Comparing the approaches: workspaces vs directories vs Terragrunt
Before diving into code, let's establish how these three strategies differ and what trade-offs each one makes.
| Approach | Isolation | Code Duplication | Operational Safety | Team Overhead |
|---|---|---|---|---|
| Workspaces | Separate state files only | Low—single config | Medium—easy to target wrong workspace | Low—minimal setup |
| Directories | Separate state, config, and credentials | Medium-High—duplicated blocks | High—explicit separation | Medium—more files to manage |
| Terragrunt | Separate state and config with DRY pattern | Low—shared modules and DRY config | High—structured, composable | High—learning curve, extra tool |
Workspaces let you manage multiple state files from a single configuration and variable set. You switch between workspaces and Terraform applies changes to that workspace's state. The appeal is simplicity—one set of code, minimal boilerplate. The risk is that it's easy to accidentally apply production changes while targeting the wrong workspace, and shared variables can bleed across environments.
Directories give you a separate folder for each environment, each with its own configuration, variables, state, and backend settings. This is more explicit and harder to get wrong by accident, but you'll duplicate resource definitions and maintain multiple configurations. Most teams find this pattern is the safest for production infrastructure.
Terragrunt is a wrapper around Terraform that removes code duplication while preserving isolation. It's powerful but adds operational complexity and requires your team to learn another tool. We'll focus on workspaces and directories in this guide, as they are the most common starting points.
Workspaces: when they fit and when they don't
Terraform workspaces are useful when you need quick, lightweight environment separation. They maintain separate state files but share the same configuration and variable definitions. This works well for short-lived or similar environments where the infrastructure is largely identical.
A workspace example: feature preview environments
Imagine your team deploys temporary preview environments for feature testing. Each preview needs an isolated database and API instance, but the configuration is the same for all of them. Workspaces are a natural fit:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "my-terraform-state"
key = "preview/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_db_instance" "preview" {
identifier = "preview-db-${terraform.workspace}"
engine = "postgres"
instance_class = "db.t3.micro"
allocated_storage = 20
username = var.db_username
password = var.db_password
skip_final_snapshot = true
tags = {
Environment = terraform.workspace
Team = "platform"
}
}
resource "aws_ecs_service" "preview_api" {
name = "preview-api-${terraform.workspace}"
cluster = var.cluster_arn
task_definition = var.task_definition_arn
desired_count = 1
tags = {
Environment = terraform.workspace
}
}
To create and switch workspaces:
terraform workspace new preview-feature-x
terraform workspace select preview-feature-x
terraform apply -var-file="preview.tfvars"
# Later, clean up
terraform workspace select default
terraform workspace delete preview-feature-x
The workspace name becomes available as terraform.workspace, so you can append it to resource names and tags to keep each environment's resources separate in AWS. This works because the preview environments are temporary and nearly identical in configuration.
Where workspaces break down
Workspaces fall apart when environments need different configurations, different providers, or different credentials. If production needs a multi-AZ RDS instance with automated backups and dev only needs a single-AZ instance with skip-final-snapshot, you'll end up with conditional logic scattered throughout your configuration. Worse, if prod and dev use different AWS accounts, you can't use the same provider block for both—the workspace can't help you there. And if a developer mistakenly runs terraform destroy while on the production workspace, all your prod resources are gone. The shared configuration and state backend create risk.
Directories: isolation by design
The directory-per-environment approach creates a separate folder for each environment, each with its own Terraform configuration, backend settings, and variable definitions. This is more verbose but makes accidents far less likely and gives you explicit control over credentials and provider settings.
Directory structure for production safety
Here's a practical layout:
infrastructure/
├── dev/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── staging/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── prod/
│ ├── main.tf
│ ├── variables.tf
│ ├── terraform.tfvars
│ └── backend.tf
└── modules/
├── vpc/
├── database/
└── api_service/
Each environment directory contains its own backend configuration, so dev, staging, and prod use separate state buckets or keys. This prevents cross-environment accidents at the infrastructure level.
Backend isolation example
In prod/backend.tf:
terraform {
backend "s3" {
bucket = "my-terraform-state-prod"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks-prod"
encrypt = true
}
}
In dev/backend.tf:
terraform {
backend "s3" {
bucket = "my-terraform-state-dev"
key = "dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks-dev"
}
}
Notice that prod uses a different bucket entirely and enables encryption. If a developer runs terraform init in the prod directory, they automatically initialize against the prod backend. There's no workspace switching where someone can accidentally target the wrong state.
Shared modules, environment-specific variables
To avoid duplication, keep the core resource definitions in shared modules and parametrize them via variables:
modules/api_service/main.tf:
resource "aws_ecs_service" "api" {
name = var.service_name
cluster = var.cluster_arn
task_definition = var.task_definition_arn
desired_count = var.desired_count
launch_type = var.launch_type
tags = {
Environment = var.environment
}
}
Then in prod/main.tf:
module "api_service" {
source = "../modules/api_service"
service_name = "api-prod"
cluster_arn = aws_ecs_cluster.prod.arn
task_definition_arn = aws_ecs_task_definition.prod.arn
desired_count = 3
launch_type = "FARGATE"
environment = "prod"
}
And in dev/main.tf:
module "api_service" {
source = "../modules/api_service"
service_name = "api-dev"
cluster_arn = aws_ecs_cluster.dev.arn
task_definition_arn = aws_ecs_task_definition.dev.arn
desired_count = 1
launch_type = "FARGATE"
environment = "dev"
}
This way, prod uses 3 instances and dev uses 1, but you're not duplicating the module definition. The directory structure enforces that dev and prod changes are applied to their respective backends.
Production isolation: a hard requirement
Production infrastructure demands strict isolation. If you use workspaces for production and staging on the same backend, a single typo in a CI/CD pipeline can destroy the wrong environment. If you use separate directories with separate AWS accounts or role credentials, the blast radius is contained.
Using separate AWS accounts
Many teams run prod in one AWS account and dev/staging in another. With directories, this is straightforward:
prod/main.tf:
provider "aws" {
region = var.aws_region
assume_role {
role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/TerraformRole"
}
}
dev/main.tf:
provider "aws" {
region = var.aws_region
assume_role {
role_arn = "arn:aws:iam::DEV_ACCOUNT_ID:role/TerraformRole"
}
}
When a developer runs terraform apply in the prod directory, they assume the prod role. When they run it in dev, they assume the dev role. The credentials are environment-specific, not configuration-specific. Workspaces don't help here—you'd still share the same provider block and have to inject different credentials via environment variables, which is error-prone.
Separate state backends with audit logging
For production, use a dedicated S3 bucket with versioning and access logging enabled:
resource "aws_s3_bucket" "terraform_state_prod" {
bucket = "my-org-terraform-state-prod"
}
resource "aws_s3_bucket_versioning" "terraform_state_prod" {
bucket = aws_s3_bucket.terraform_state_prod.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_logging" "terraform_state_prod" {
bucket = aws_s3_bucket.terraform_state_prod.id
target_bucket = aws_s3_bucket.terraform_logs.id
target_prefix = "prod-state/"
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state_prod" {
bucket = aws_s3_bucket.terraform_state_prod.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
This setup ensures that every state change is logged and versioned, and you can audit who accessed production state and when. Workspaces on a shared backend can't provide this level of isolation.
Workspaces fit: short-lived environments and testing
Workspaces shine in scenarios where you need temporary, identical environments and safety is less critical:
- Feature preview environments: Each developer branch gets a workspace with its own database and API instance. Once the PR is merged, delete the workspace.
- Load testing: Spin up a workspace identical to staging, run your load tests, then tear it down.
- Hotfix validation: Create a workspace from prod-like configuration, apply the hotfix, validate it, then destroy.
In all these cases, environments are nearly identical, temporary, and the cost of an accident is low. Workspaces let you do this with minimal code and zero infrastructure overhead.
Directory approach fit: multi-env production systems
Use directories when:
- Environments have significantly different configurations (instance sizes, counts, features).
- Environments use different AWS accounts or credentials.
- You need strict audit trails and state isolation.
- You want to prevent accidental cross-environment changes.
- You're managing production infrastructure where downtime is costly.
Most production systems fall into this category. The directory approach requires more files but eliminates entire classes of mistakes.
Common mistakes
Mistake 1: Using workspaces for prod and staging on the same backend
This couples your most critical infrastructure with non-critical infrastructure via a single state backend. If a CI/CD script targets the wrong workspace, production goes down. Always use separate directories for prod and staging at minimum.
Mistake 2: Sharing provider blocks across environments
If your dev and prod backends are separate directories but your provider block uses environment variables to swap credentials, you're still at risk. A developer might forget to set the AWS_PROFILE variable before running apply. With separate directories, each with its own provider block pointing to a specific role, there's no ambiguity.
Mistake 3: Mixing workspaces and directories
Some teams use directories for dev and prod but workspaces within the prod directory for "sub-environments." This is confusing and makes it hard for new team members to understand the isolation model. Pick one approach and stick with it across your organization.
Mistake 4: Over-engineering with Terragrunt too early
Terragrunt is powerful, but if your team is still learning Terraform, adding an abstraction layer on top creates cognitive overhead. Start with directories, and if code duplication becomes unmanageable, migrate to Terragrunt later. Most teams find directories are sufficient for years.
Mistake 5: Not versioning or locking state backends
Whether you use workspaces or directories, enable S3 versioning and DynamoDB locks on all state backends. This prevents accidental state corruption and lets you recover from mistakes.
Be consistent: one approach across the organization
The worst scenario is when different teams in your organization use different approaches. A feature team uses workspaces for their service, the platform team uses directories for infrastructure, and the data team uses both. This creates confusion during knowledge transfer, makes it hard to write organization-wide tooling, and increases the chance of mistakes.
Have a conversation with your team early about which approach you'll standardize on. Document the decision in your runbooks and onboarding materials. If you have mixed workloads, you might use workspaces for non-production temporary environments and directories for anything touching production state.
Example organizational standard:
- Use directories for prod, staging, and any shared infrastructure (networking, databases, IAM).
- Use workspaces for ephemeral developer environments and feature previews that will be cleaned up in days.
- All backends are in separate AWS accounts when possible; minimum is separate S3 buckets.
- All backends have versioning enabled and DynamoDB locks configured.
- Code review is required for any changes to prod and staging backends.
Write this down. Share it with every engineer who touches infrastructure. As your team and systems grow, consistency pays dividends.
Key takeaways
- Workspaces are lightweight and suitable for short-lived, nearly-identical environments. They share configuration and backend settings, which makes them simple but risky for production.
- Directories provide explicit isolation of state, credentials, and configuration. They require more files but are safer and clearer for production infrastructure.
- Production infrastructure demands separate backends and credentials. Use directories with separate AWS accounts or at minimum separate S3 buckets and DynamoDB tables.
- Use workspaces for feature previews, load testing, and temporary developer environments. Clean them up when they're no longer needed.
- Use directories for any environment used by multiple team members or that holds long-lived infrastructure state.
- Standardize your approach across your organization. Mixed strategies create confusion and increase operational risk.
- Enable versioning and locking on all state backends regardless of your approach. This prevents corruption and enables recovery.






Comments
Loading comments…