Skip to main content

Migrating from Terraform Workspaces

Terraform workspaces solve a simple problem: deploy the same code to multiple environments using one state backend. But as your infrastructure grows, workspaces create more problems than they solve. Atmos provides a better path forward.

Why Workspaces Fall Short

Workspaces seem great at first—one command (terraform workspace select prod) and you're deploying to production. But they have fundamental limitations:

1. Shared State Backend = Single Point of Failure

All workspaces share the same backend configuration. One misconfigured workspace can corrupt state for all environments.

# ALL workspaces use this backend
terraform {
backend "s3" {
bucket = "my-terraform-state" # Same bucket for dev, staging, prod
key = "terraform.tfstate" # Workspace adds prefix, but it's fragile
region = "us-east-1"
}
}

A typo in dev can overwrite prod state. This has happened to many teams.

2. No Configuration Differentiation

Workspaces use the same variables file. You end up with massive case statements:

variables.tf

variable "instance_type" {
default = "t3.micro"
}

locals {
# Ugly workspace-specific logic
instance_type = terraform.workspace == "prod" ? "m5.large" :
terraform.workspace == "staging" ? "t3.medium" :
"t3.micro"

enable_monitoring = terraform.workspace == "prod" ? true : false

# This gets unmaintainable quickly
}

3. Hidden State

Which workspaces exist? What resources do they contain? You have to inspect the state backend or track it manually.

$ terraform workspace list
default
dev
staging
prod
old-test-workspace # Is this safe to delete?
johns-experiment # What is this?

4. No Code Reuse

Every workspace runs the exact same code. Want to test a new module version in dev before prod? Tough luck. You need to branch your entire repository.

5. Blast Radius

All environments in one state backend means one mistake can affect all environments. Separate state backends provide isolation.

How Atmos Solves These Problems

Atmos separates code (Terraform components) from configuration (stack YAML files). This gives you:

Atmos Advantages

  • Isolated state - Each stack has its own backend configuration
  • Environment-specific config - No giant case statements
  • Visible configuration - Stacks are files you can search, version, review
  • Component reuse - Share code, customize config via inheritance
  • Reduced blast radius - Isolated state backends per environment

Migration Strategy

Before: Workspace-Based Setup

main.tf

terraform {
backend "s3" {
bucket = "terraform-state"
key = "vpc/terraform.tfstate"
region = "us-east-1"
}
}

variable "environment" {
default = "dev"
}

variable "cidr_block" {
default = "10.0.0.0/16"
}

locals {
# Workspace-specific logic
cidr_block = terraform.workspace == "prod" ? "10.100.0.0/16" :
terraform.workspace == "staging" ? "10.50.0.0/16" :
"10.0.0.0/16"
}

resource "aws_vpc" "main" {
cidr_block = local.cidr_block

tags = {
Environment = terraform.workspace
}
}

Deploy:

terraform workspace select prod
terraform apply # Which VPC am I deploying? Not obvious!

After: Atmos Stacks

Step 1: Create reusable component (generic code)

components/terraform/vpc/main.tf

# No workspace logic!
variable "cidr_block" {
description = "VPC CIDR block"
type = string
}

variable "environment" {
description = "Environment name"
type = string
}

resource "aws_vpc" "main" {
cidr_block = var.cidr_block

tags = {
Environment = var.environment
}
}

Step 2: Create environment-specific stacks (config)

stacks/prod.yaml

terraform:
backend_type: s3
backend:
s3:
bucket: terraform-state-prod # Isolated backend
key: terraform.tfstate
region: us-east-1

components:
terraform:
vpc:
backend:
s3:
workspace_key_prefix: vpc
vars:
cidr_block: "10.100.0.0/16"
environment: prod

stacks/dev.yaml

terraform:
backend_type: s3
backend:
s3:
bucket: terraform-state-dev # Separate backend!
key: terraform.tfstate
region: us-east-1

components:
terraform:
vpc:
backend:
s3:
workspace_key_prefix: vpc
vars:
cidr_block: "10.0.0.0/16"
environment: dev

Deploy:

atmos terraform apply vpc -s prod  # Crystal clear: VPC in prod
atmos terraform apply vpc -s dev # VPC in dev

No workspace selection. No hidden state. Just explicit, declarative configuration.

Learn more: atmos terraform apply | atmos terraform plan

Step-by-Step Migration

1. Extract Workspace Logic

Identify all workspace-specific logic in your Terraform code:

Before:

locals {
instance_type = terraform.workspace == "prod" ? "m5.large" : "t3.small"
enable_monitoring = terraform.workspace == "prod" ? true : false
backup_retention = terraform.workspace == "prod" ? 30 : 7
}

After (convert to variables):

components/terraform/app/variables.tf

variable "instance_type" {
description = "EC2 instance type"
type = string
}

variable "enable_monitoring" {
description = "Enable CloudWatch monitoring"
type = bool
}

variable "backup_retention" {
description = "Backup retention in days"
type = number
}

2. Create Stack Configurations

For each workspace, create a stack file:

stacks/prod.yaml

components:
terraform:
app:
vars:
instance_type: m5.large
enable_monitoring: true
backup_retention: 30

stacks/dev.yaml

components:
terraform:
app:
vars:
instance_type: t3.small
enable_monitoring: false
backup_retention: 7

3. Migrate State Backends

Critical: This step requires careful planning to avoid state loss.

Option A: Keep Workspace State (Easiest)

You can keep using workspace-based state with Atmos:

stacks/prod.yaml

terraform:
backend_type: s3
backend:
s3:
bucket: terraform-state # Same bucket
key: vpc/terraform.tfstate
region: us-east-1
workspace_key_prefix: env # Uses workspace structure

components:
terraform:
vpc:
settings:
terraform:
workspace: prod # Selects workspace "prod"

This lets you migrate incrementally without touching state.

Option B: Migrate to Separate Backends (Recommended)

For better isolation, migrate each workspace to its own backend:

  1. Export state from workspace:

    terraform workspace select prod
    terraform state pull > prod.tfstate
  2. Configure new backend:

    stacks/prod.yaml

    terraform:
    backend_type: s3
    backend:
    s3:
    bucket: terraform-state-prod # New bucket
    key: vpc.tfstate
    region: us-east-1
  3. Initialize and push state:

    atmos terraform init vpc -s prod
    terraform state push prod.tfstate
  4. Verify:

    atmos terraform plan vpc -s prod  # Should show no changes

4. Remove Workspace Logic

Clean up your Terraform code:

Remove:

# DELETE workspace references
locals {
env = terraform.workspace # Remove
}

# DELETE workspace conditionals
count = terraform.workspace == "prod" ? 1 : 0 # Remove

Replace with variables:

variable "environment" {
description = "Environment name"
type = string
}

variable "create_feature" {
description = "Whether to create optional feature"
type = bool
default = false
}

5. Update CI/CD

Before:

# Old CI/CD
terraform workspace select $ENV
terraform plan
terraform apply -auto-approve

After:

# New CI/CD
atmos terraform plan $COMPONENT -s $STACK
atmos terraform apply $COMPONENT -s $STACK -auto-approve

GitHub Actions example:

- name: Deploy VPC
run: |
atmos terraform apply vpc -s ${{ matrix.stack }}
strategy:
matrix:
stack: [dev, staging, prod]

Handling Common Patterns

Pattern 1: Workspace-Specific Resources

Before:

resource "aws_instance" "monitoring" {
count = terraform.workspace == "prod" ? 1 : 0
# ...
}

After:

components/terraform/monitoring/main.tf

variable "enabled" {
type = bool
default = false
}

resource "aws_instance" "this" {
count = var.enabled ? 1 : 0
# ...
}

stacks/prod.yaml

components:
terraform:
monitoring:
vars:
enabled: true

stacks/dev.yaml

components:
terraform:
monitoring:
vars:
enabled: false

Pattern 2: Workspace in Tags

Before:

tags = {
Environment = terraform.workspace
}

After:

components/terraform/vpc/main.tf

variable "environment" {
type = string
}

tags = {
Environment = var.environment
}

stacks/prod.yaml

vars:
environment: production

components:
terraform:
vpc:
vars:
environment: '{{ .vars.environment }}'

Pattern 3: Workspace-Specific Data Sources

Before:

data "aws_ami" "app" {
most_recent = true

filter {
name = "name"
values = [terraform.workspace == "prod" ? "prod-ami-*" : "dev-ami-*"]
}
}

After:

components/terraform/app/main.tf

variable "ami_prefix" {
type = string
}

data "aws_ami" "app" {
most_recent = true

filter {
name = "name"
values = ["${var.ami_prefix}-*"]
}
}

stacks/prod.yaml

components:
terraform:
app:
vars:
ami_prefix: prod-ami

Migration Checklist

  • Audit all terraform.workspace references in code
  • Convert workspace conditionals to variables
  • Create Atmos atmos.yaml configuration
  • Create stack files for each workspace
  • Test state migration in dev first
  • Migrate state (keep workspace structure OR move to separate backends)
  • Verify atmos terraform plan shows no changes
  • Update CI/CD pipelines
  • Update team documentation
  • Deprecate workspace commands

Benefits After Migration

  • Explicit configuration - No hidden workspace state
  • Isolated backends - Prod can't accidentally affect dev
  • Environment-specific settings - No complex conditionals
  • Better code review - Stack changes visible in YAML diffs
  • Reusable components - Same code, different configs
  • Easier testing - Deploy different component versions per stack

Common Questions

Get Help

Migrating from workspaces? We're here to help:

Next Steps

Ready to get started?