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?