
Terraform workspaces let you manage several distinct state files for the same configuration. By default, Terraform runs 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.
That lets you deploy the same infrastructure code to different environments (development, staging, production) without changing the code itself. You just switch 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 lets a configuration reference the current workspace name at runtime, so you can vary things slightly 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 word "workspace" means different things 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 give you state isolation for different environments or parallel development. But they have some built-in limits:
terraform.workspace becomes unwieldy at scaleCLI workspaces are best suited for:

Terraform Automation and Collaboration Software (TACOs) like Terraform Cloud and Scalr grew the workspace idea into a full management unit. These remote workspaces act as distinct deployment targets with:

You'll want remote workspaces for:
The line between the two models can bite you 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.
When you manage configurations across development, staging, and production, consistency and control matter most. Most environment differences live in input variables and tfvars files. Keep environment-specific values there, not hardcoded across configurations.
A widely accepted practice in the Terraform community is to use separate directories for distinct, long-lived environments. This gives you the most 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. Don't use workspaces for distinct prod/staging/dev environments, because they share backend configuration and tend to push you into tangled 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:
Whether you go with a centralized (monorepo) or distributed (polyrepo) structure shapes how you scale, how you collaborate, and how much operational complexity you take on. 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 decides whether you can scale it, keep it secure, and collaborate on it. For a detailed 4-part treatment, 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 the foundation for DRY (Don't Repeat Yourself) infrastructure. Bad module design leaves you with duplicated code and a maintenance headache.
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 carefully.
State Splitting:
Don't keep one monolithic state file. Break state down 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 these dependencies carefully so you don't end up with overly complex or circular relationships.
As infrastructure grows, basic workspace management stops keeping up, and you need patterns built for scale.
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 needs a sharper approach to managing environments. 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, and it should weigh heavily when you're comparing Terraform Cloud alternatives. Concurrency-based pricing (used by some alternatives) sells a fixed pool of parallel run slots, which locks you into 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, which only bills runs that actually executed, sidesteps the trade-off. That matters when environment counts grow non-linearly with team and region count.
Key Metrics:
Feedback Loops:
Managing Terraform environments well means getting past the basic workspace commands. As teams and regions multiply, CLI workspaces give way to remote platforms that handle the RBAC, policy, and state isolation the CLI can't.
Key Takeaways:
Start with directory-based separation and remote state isolation, then add policy enforcement and run triggers as your environment count climbs. Each step is checkable: open a tfvars-only PR to confirm it plans, audit for shadowing variable overrides before a credential rotation, and keep reserved tfvars filenames out of version control.
terraform init and Backend Config: the backend-per-environment pattern in depth.