
Terraform workspaces provide a mechanism to manage multiple distinct state files for the same configuration. By default, Terraform operates in a workspace named default. When you create a new workspace (e.g., terraform workspace new dev), Terraform creates a separate state file for that workspace (e.g., terraform.tfstate.d/dev/terraform.tfstate). All operations within that workspace read from and write to its dedicated state file.
This allows you to deploy the same infrastructure code to different environments—development, staging, production—without altering the code itself, by simply switching workspaces.
Key Terraform CLI workspace commands include:
terraform workspace new <workspace-name>: Creates a new workspaceterraform workspace list: Lists existing workspacesterraform workspace select <workspace-name>: Switches to a different workspaceterraform workspace show: Displays the current workspace nameterraform workspace delete <workspace-name>: Removes a workspaceThe terraform.workspace interpolation allows configurations to dynamically reference the current workspace name, enabling slight variations based on the active environment:
resource "aws_instance" "example" {
ami = "ami-0c55b31ad20f0c502"
instance_type = terraform.workspace == "prod" ? "t2.medium" : "t2.micro"
tags = {
Name = "Server-${terraform.workspace}"
Environment = terraform.workspace
}
}The term "workspace" carries different meanings across Terraform's ecosystem. Both depend on a properly-configured backend — see our Terraform state and remote backends guide for the underlying state model:
CLI workspaces are a local feature for managing multiple state files from a single Terraform configuration. They enable state isolation for different environments or parallel development scenarios. However, they have inherent limitations:
terraform.workspace becomes unwieldy at scaleCLI workspaces are best suited for:

Terraform Automation and Collaboration Software (TACOs) like Terraform Cloud and Scalr evolved workspace concepts into comprehensive management units. These remote workspaces serve as distinct deployment targets with:

Remote workspaces are essential for:
The boundary between the two models can bite when you mix them. A customer using the CLI-driven workflow against a remote-execution workspace found that a local tofu plan prompted interactively for every variable before handing the run off to the platform, while VCS-triggered runs on the same workspace ran clean. Platform-managed variables are injected into the remote run, but anything the CLI evaluates locally before handoff can still demand values it cannot see. If your team runs both CLI-driven and VCS-driven workflows against the same workspaces, test both paths — they resolve variables at different points.
Consistency and control are paramount when managing configurations across development, staging, and production environments. Most environment differences live in input variables and tfvars files — keep environment-specific values there, not hardcoded across configurations.
A widely-accepted best practice in the Terraform community is using separate directories for distinct, long-lived environments. This approach provides maximum isolation:
.
├── environments
│ ├── development
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ └── dev.tfvars
│ ├── staging
│ │ ├── backend.tf
│ │ ├── main.tf
│ │ └── staging.tfvars
│ └── production
│ ├── backend.tf
│ ├── main.tf
│ └── prod.tfvars
├── modules
│ ├── vpc
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── ec2_instance
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
Benefits of directory-based separation:
One detail that gets overlooked in this layout: where the tfvars files live relative to what your automation watches. A team we worked with at Scalr kept per-environment values outside the Terraform directory — iac/scalr/prod.tfvars sitting next to iac/infra/ — with the workspace's VCS trigger strategy set to Directories, pointed at the infra directory. PRs touching .tofu files triggered plans as expected. PRs that only changed prod.tfvars triggered nothing, so production variable changes were merging without ever being planned. The trigger strategy matched directories, and the tfvars path is a file, so it never matched anything. The fix was gitignore-style trigger patterns listing both the infra directory and the tfvars file explicitly. If your variable files live outside the directory your CI watches, open a tfvars-only PR and confirm it actually produces a plan.
When to use workspaces instead:
Use CLI workspaces for short-term, temporary environments built from the exact same configuration—such as an isolated sandbox for testing a feature branch or a parallel environment for code review. Workspaces are not recommended for distinct prod/staging/dev environments due to shared backend configurations and the potential for complex conditional logic.
Define all variables in variables.tf and use .tfvars files for environment-specific values:
# variables.tf
variable "aws_region" {
description = "AWS region"
type = string
}
variable "instance_count" {
description = "Number of instances"
type = number
}
variable "instance_type" {
description = "EC2 instance type"
type = string
}Then create environment-specific files:
# environments/production/prod.tfvars
aws_region = "us-east-1"
instance_count = 5
instance_type = "m5.large"# environments/development/dev.tfvars
aws_region = "us-west-2"
instance_count = 1
instance_type = "t2.micro"Critical rule: Never store sensitive data in .tfvars files. Use dedicated secret managers (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault) and integrate them with your CI/CD pipeline or Terraform provider.
A second rule we now state explicitly after seeing it fail in the field: define each variable in exactly one place. One customer running OpenTofu 1.8+ had plans succeed and applies fail with a variable conflict. The same variable was set in an explicit -var-file and again in a *.auto.tfvars file, with different values. During plan, the explicit -var-file wins. During apply of a saved plan, only auto-loaded files are read — and OpenTofu 1.8+ validates apply-time variable values against the saved plan, so the mismatch rejected the apply. Nothing about the configuration was wrong in isolation; the two definitions only collided across the plan/apply boundary.
Strive for staging environments that closely mirror production:
Establish a clear progression path:
develop, staging, main)terraform plan output in PRs for visibilityOverreliance on workspaces for environment separation leads to hidden risks:
# ❌ Problematic: Workspace-based environment separation
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "terraform.tfstate"
region = "us-west-2"
}
}
resource "aws_instance" "example" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = terraform.workspace == "prod" ? "m5.large" : "t2.micro"
tags = {
Environment = terraform.workspace
}
}Problems with this approach:
# ✅ Recommended: Directory-based separation
# ./environments/prod/main.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "environments/prod/terraform.tfstate"
region = "us-west-2"
}
}
module "application" {
source = "../../modules/application"
environment = "prod"
instance_type = "m5.large"
}
# ./environments/dev/main.tf
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "environments/dev/terraform.tfstate"
region = "us-west-2"
}
}
module "application" {
source = "../../modules/application"
environment = "dev"
instance_type = "t2.micro"
}Advantages:
The choice between centralized (monorepo) and distributed (polyrepo) repository structures fundamentally affects scalability, collaboration, and operational complexity. The repo-structure decision is tightly coupled to your pipeline design — see CI/CD and GitOps for Terraform & OpenTofu for the trade-offs.
Architecture:
my-infrastructure-monorepo/
├── environments/
│ ├── dev/
│ ├── staging/
│ └── prod/
├── modules/
│ ├── vpc/
│ ├── database/
│ └── app-tier/
├── policies/
└── docs/
Advantages:
Disadvantages:
Best for:
Architecture:
my-org/
├── infra-networking/ (VPC, subnets, routing)
├── infra-database/ (RDS, data warehouse)
├── infra-app/ (Application tier resources)
├── terraform-modules/ (Shared module library)
└── platform-policies/ (OPA policies)
Advantages:
Disadvantages:
Best for:
Choose based on:
Pro tip: Regardless of choice, use centralized platforms like Scalr that provide unified management across both monorepo and polyrepo structures.

How you organize Terraform code determines your ability to scale, maintain security, and collaborate effectively. For a 4-part deep dive, see our Structuring Terraform and OpenTofu series, Part 1/4. For module fundamentals, Terraform Modules Explained.
Standard File Layout:
Every module should follow a consistent structure:
# versions.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}One warning about the terraform.tfvars name specifically. TACO platforms generate terraform.tfvars/terraform.tfvars.json (and opentofu.tfvars) at run time to inject platform-managed variables, so a file by that exact name in your repo is ignored on remote runs. In Scalr's support queue, this shows up repeatedly during platform evaluations: two separate teams hit it weeks apart during their proofs-of-concept, both with Error: No value for required variable on their first remote run, for code that worked fine via the CLI locally. The fix is to rename the file — myproject.tfvars passed with an explicit -var-file — and keep the reserved names out of version control. Scalr has since added validation that flags reserved tfvars filenames, so the failure names the cause instead of surfacing as a generic missing-variable error.
Naming Conventions:
aws_instance.web_server)ram_size_gb, enable_monitoring)vpc_id, database_endpoint)network.tf, compute.tf, security.tf)Modules are your foundation for DRY (Don't Repeat Yourself) infrastructure. Poor module design leads to code duplication and maintenance nightmares.
Module Design Principles:
Example well-designed module:
# modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = var.enable_dns_hostnames
tags = {
Name = "${var.project_name}-vpc"
Project = var.project_name
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# modules/vpc/variables.tf
variable "project_name" {
description = "The name of the project"
type = string
}
variable "cidr_block" {
description = "The CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "enable_dns_hostnames" {
description = "Enable DNS hostnames in the VPC"
type = bool
default = true
}
# modules/vpc/outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
output "internet_gateway_id" {
description = "The ID of the internet gateway"
value = aws_internet_gateway.main.id
}State is critical. Protect and manage it meticulously.
State Splitting:
Avoid monolithic state files. Break down state by environment, region, and component:
production/
networking/terraform.tfstate
app-main/terraform.tfstate
databases/terraform.tfstate
staging/
networking/terraform.tfstate
app-main/terraform.tfstate
Benefits:
The harder question is what to split by. One member of the Scalr community came to us with single workspaces holding state for entire resource groups — several hundred to a few thousand resources each. Plans took long enough to discourage running them, and the diffs were effectively unreviewable. Their instinct was to split per team, but they feared the workspace sprawl that would create. What worked was splitting by blast radius and change frequency instead:
Team boundaries and raw resource counts are poor splitting criteria; how often things change, and what breaks when they do, are the ones that hold up.
Remote Backends:
Always use remote backends (AWS S3, Azure Blob Storage, Google Cloud Storage) with:
terraform {
backend "s3" {
bucket = "my-tf-state-bucket-prod"
key = "production/networking/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "my-tf-state-lock-prod"
encrypt = true
}
}Essential remote backend features:
Logical Backend Keys:
Structure paths consistently for easy management:
env:/<environment>/<region>/<component>/terraform.tfstate
Example:
env:/production/us-east-1/networking/terraform.tfstate
env:/production/us-east-1/app-tier/terraform.tfstate
env:/staging/us-east-1/networking/terraform.tfstate
Managing Dependencies:
Use terraform_remote_state to read outputs from other isolated state files:
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "my-tf-state-bucket"
key = "production/networking/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.networking.outputs.subnet_id
# ...
}Design dependencies carefully to avoid overly complex or circular relationships.
As infrastructure grows, basic workspace management becomes insufficient. Sophisticated approaches are required.
terraform.workspace makes configurations complex and error-prone
Hierarchical variable scoping cuts both ways, because lower scopes win. A customer rotating credentials updated two secret keys at the organization level, yet one workspace kept authenticating with the old values — "it worked last week" was the entire bug report. Two causes compounded. First, the new values had been set as Terraform-category variables, which the platform writes into a *.auto.tfvars file rather than the process environment — so a provider that auto-discovers credentials from environment variables never saw them. Second, the workspace had its own workspace-scoped shell variables with the same names, and in account → environment → workspace inheritance the workspace value overrides everything above it, pinning every run to the stale credentials. The fix was threefold: set credentials as shell-category variables, delete the workspace-level overrides, and mark the upstream values final so lower scopes can no longer override them. If you adopt hierarchical scoping, audit for shadowing overrides before any credential rotation, not after.
# environments/prod/vpc/terragrunt.hcl
terraform {
source = "../../../modules/vpc"
}
inputs = {
cidr_block = "10.0.0.0/16"
environment = "prod"
}global/terraform.tfvars (common to all envs)
environments/prod/terraform.tfvars (prod-specific)
environments/prod/backend.tf (prod backend)
Terraform Cloud / Scalr:
Remote workspaces support structured dependencies through:
tfe_outputs (TFC) or terraform_remote_stateExample dependency flow:
Networking Workspace (creates VPC, subnets)
↓ (Run Trigger)
Application Workspace (references networking outputs)
↓ (Run Trigger)
Database Workspace (references app outputs)
Modern infrastructure requires evolved approaches to environment management. Layer in Policy as Code so policies apply per-environment, drift detection to catch out-of-band changes, and audit logs for compliance evidence.
Secret Management:
sensitive = trueAccess Control:
Network Security as Code:
CI/CD Pipeline Stages:
terraform fmt)Mandatory Approval Gates:
terraform plan in PR for visibilityUse Open Policy Agent (OPA) with Rego or HashiCorp Sentinel to automatically enforce policies:
# Enforce S3 encryption
package terraform.analysis
deny[msg] {
bucket := input.resource_changes[_]
bucket.type == "aws_s3_bucket"
bucket.mode == "managed"
not bucket.change.after.server_side_encryption_configuration
msg := sprintf("S3 Bucket '%s' must have server-side encryption", [bucket.name])
}Policies can enforce:
Terragrunt: Wrapper for Terraform that:
run-all commands for bulk operationsAtlantis: Enables GitOps workflows:
terraform plan on PR creationTACO Platforms (Scalr, Terraform Cloud, Spacelift, Env0):
When you're managing many environments, the platform's pricing model matters as much as its feature list. Concurrency-based pricing (used by some alternatives) sells a fixed pool of parallel run slots, which forces a permanent trade-off: too few slots and dev/staging/prod pipelines queue behind each other during releases, too many and you're renting idle capacity. Usage-based, per-run pricing — only billing runs that actually executed — sidesteps the trade-off, which matters when environment counts grow non-linearly with team and region count.
Key Metrics:
Feedback Loops:
Managing Terraform environments effectively requires moving beyond basic workspace commands to adopt a comprehensive, disciplined approach. The evolution from CLI workspaces to sophisticated remote platforms reflects the growing operational complexity of infrastructure at scale.
Key Takeaways:
By implementing these practices, you can build secure, scalable Terraform environments that support your team's growth while maintaining stability, security, and compliance across your entire infrastructure-as-code footprint.
terraform init and Backend Config — the backend-per-environment pattern in depth.