Managing Multiple Environments with Terraform
Learn strategies for handling development, staging, and production infrastructure with the same Terraform code
TLDR: Use workspaces for simple environment separation with shared code. For complex setups, use separate directories with shared modules and environment-specific variable files. Never share state files between environments. Structure code so environments are isolated but share common patterns through modules.
Most projects need multiple environments - development for testing changes, staging for integration testing, and production for live traffic. Terraform provides several approaches for managing these environments.
Workspaces: Simple Environment Separation
Terraform workspaces let you maintain multiple state files from the same configuration. Each workspace has its own state.
Understanding Workspaces
By default, you work in the "default" workspace. Create additional workspaces for other environments:
# List workspaces
terraform workspace list
# Create new workspace
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# Switch between workspaces
terraform workspace select dev
terraform workspace select prod
# Show current workspace
terraform workspace show
When you switch workspaces, Terraform uses a different state file. This lets you create the same infrastructure in multiple environments.
Using the Current Workspace in Configuration
Reference the current workspace name in your configuration:
locals {
environment = terraform.workspace
# Environment-specific settings
instance_count = terraform.workspace == "prod" ? 5 : 1
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
}
resource "aws_instance" "web" {
count = local.instance_count
ami = data.aws_ami.ubuntu.id
instance_type = local.instance_type
tags = {
Name = "web-${terraform.workspace}-${count.index}"
Environment = terraform.workspace
}
}
output "environment" {
value = "Deployed to ${terraform.workspace}"
}
Deploy to different environments:
# Deploy to dev
terraform workspace select dev
terraform apply
# Deploy to prod
terraform workspace select prod
terraform apply
Workspace Limitations
Workspaces work well for simple scenarios but have limitations:
Same backend: All workspaces use the same backend. You can't have dev in one AWS account and prod in another.
Easy mistakes: Switching to the wrong workspace and running apply can modify the wrong environment.
Implicit configuration: Which workspace means which environment isn't obvious from the code.
No isolation: All workspaces share the same code and backend. A bad change affects all environments.
For production systems, workspace limitations often push teams toward directory-based separation.
Directory-Based Environments
A more robust approach: separate directories for each environment, sharing code through modules.
Directory Structure
terraform/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── web_server/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── database/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── environments/
├── 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
Each environment directory has its own:
- State file (separate backends)
- Variable values
- Provider configurations
But they all use the same modules, keeping infrastructure patterns consistent.
Example: Development Environment
# environments/dev/main.tf
terraform {
required_version = "~> 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "mycompany-terraform-state"
key = "dev/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = "dev"
ManagedBy = "terraform"
Project = var.project
}
}
}
module "vpc" {
source = "../../modules/vpc"
cidr_block = var.vpc_cidr
availability_zones = var.availability_zones
name = "${var.project}-dev"
}
module "web_servers" {
source = "../../modules/web_server"
count = var.web_server_count
name = "${var.project}-dev-web-${count.index}"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnets[count.index % length(module.vpc.public_subnets)]
ami_id = var.web_server_ami
instance_type = var.web_server_instance_type
}
module "database" {
source = "../../modules/database"
identifier = "${var.project}-dev-db"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
instance_class = var.db_instance_class
allocated_storage = var.db_allocated_storage
}
Development variables in environments/dev/terraform.tfvars:
# environments/dev/terraform.tfvars
project = "webapp"
aws_region = "us-east-1"
availability_zones = ["us-east-1a", "us-east-1b"]
vpc_cidr = "10.0.0.0/16"
web_server_count = 1
web_server_instance_type = "t3.micro"
web_server_ami = "ami-0c55b159cbfafe1f0"
db_instance_class = "db.t3.micro"
db_allocated_storage = 20
Example: Production Environment
Production uses the same modules but different configurations:
# environments/prod/main.tf
terraform {
required_version = "~> 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "mycompany-terraform-state"
key = "prod/terraform.tfstate" # Different state file
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Environment = "prod"
ManagedBy = "terraform"
Project = var.project
}
}
}
# Same modules, different configurations
module "vpc" {
source = "../../modules/vpc"
cidr_block = var.vpc_cidr
availability_zones = var.availability_zones
name = "${var.project}-prod"
}
module "web_servers" {
source = "../../modules/web_server"
count = var.web_server_count
name = "${var.project}-prod-web-${count.index}"
vpc_id = module.vpc.vpc_id
subnet_id = module.vpc.public_subnets[count.index % length(module.vpc.public_subnets)]
ami_id = var.web_server_ami
instance_type = var.web_server_instance_type
}
module "database" {
source = "../../modules/database"
identifier = "${var.project}-prod-db"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
instance_class = var.db_instance_class
allocated_storage = var.db_allocated_storage
# Production-specific settings
multi_az = true
backup_retention_period = 7
deletion_protection = true
}
Production variables in environments/prod/terraform.tfvars:
# environments/prod/terraform.tfvars
project = "webapp"
aws_region = "us-east-1"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
vpc_cidr = "10.1.0.0/16"
web_server_count = 5
web_server_instance_type = "t3.large"
web_server_ami = "ami-0c55b159cbfafe1f0"
db_instance_class = "db.t3.medium"
db_allocated_storage = 100
Working with Directory-Based Environments
Deploy to each environment separately:
# Deploy development
cd environments/dev
terraform init
terraform plan
terraform apply
# Deploy production
cd ../prod
terraform init
terraform plan
terraform apply
Each environment is completely isolated:
- Separate state files
- Different AWS accounts possible (using different provider credentials)
- Independent apply operations
- No risk of accidentally affecting the wrong environment
Environment-Specific Resources
Sometimes environments need fundamentally different resources. Use conditionals or separate configurations:
# Only create in production
resource "aws_cloudwatch_alarm" "high_cpu" {
count = var.environment == "prod" ? 1 : 0
alarm_name = "high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "CPUUtilization"
namespace = "AWS/EC2"
period = 120
statistic = "Average"
threshold = 80
alarm_actions = [aws_sns_topic.alerts[0].arn]
}
# SNS topic only in production
resource "aws_sns_topic" "alerts" {
count = var.environment == "prod" ? 1 : 0
name = "infrastructure-alerts"
}
Or better, make this explicit in environment-specific code:
# environments/prod/monitoring.tf
resource "aws_cloudwatch_alarm" "high_cpu" {
# Production monitoring configuration
}
resource "aws_sns_topic" "alerts" {
# Production alerting configuration
}
Development and staging don't have these files, making the differences clear.
Shared Configuration with Terragrunt
Terragrunt is a tool that adds extra functionality to Terraform, particularly useful for managing multiple environments.
Install Terragrunt:
# macOS
brew install terragrunt
# Linux
wget https://github.com/gruntwork-io/terragrunt/releases/download/v0.54.0/terragrunt_linux_amd64
chmod +x terragrunt_linux_amd64
sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt
Structure with Terragrunt:
terraform/
├── modules/
│ └── web_server/
│ └── main.tf
├── terragrunt.hcl # Root configuration
└── environments/
├── dev/
│ └── terragrunt.hcl
├── staging/
│ └── terragrunt.hcl
└── prod/
└── terragrunt.hcl
Root terragrunt.hcl with shared config:
# terragrunt.hcl
remote_state {
backend = "s3"
config = {
bucket = "mycompany-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Environment-specific terragrunt.hcl:
# environments/dev/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../modules//web_server"
}
inputs = {
environment = "dev"
instance_type = "t3.micro"
instance_count = 1
}
# environments/prod/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../modules//web_server"
}
inputs = {
environment = "prod"
instance_type = "t3.large"
instance_count = 5
}
Use Terragrunt instead of Terraform:
cd environments/dev
terragrunt plan
terragrunt apply
Terragrunt reduces duplication while keeping environments isolated.
Cross-Environment References
Occasionally environments need to reference each other (dev needs the prod VPC ID for VPC peering). Use data sources with explicit state references.
In production, output the VPC ID:
# environments/prod/outputs.tf
output "vpc_id" {
value = module.vpc.vpc_id
}
In development, read prod's state:
# environments/dev/main.tf
data "terraform_remote_state" "prod" {
backend = "s3"
config = {
bucket = "mycompany-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_vpc_peering_connection" "dev_to_prod" {
vpc_id = module.vpc.vpc_id
peer_vpc_id = data.terraform_remote_state.prod.outputs.vpc_id
tags = {
Name = "dev-to-prod-peering"
}
}
Use this sparingly. Too many cross-environment references create tight coupling.
Choosing an Approach
Use workspaces when:
- Learning Terraform
- Simple setups with identical environments
- All environments in the same AWS account/subscription
- Team is small and coordination is easy
Use directory-based separation when:
- Environments are in different accounts
- Production needs strict access controls
- Environments differ significantly
- Multiple team members work on infrastructure
- You need strong isolation between environments
Most production systems benefit from directory-based separation. The extra structure prevents mistakes and makes differences between environments explicit.
Practical Example: Complete Multi-Environment Setup
Here's a realistic directory structure:
terraform/
├── modules/
│ ├── networking/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── compute/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── database/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── environments/
├── dev/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── backend.tf
│ └── terraform.tfvars
├── staging/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── backend.tf
│ └── terraform.tfvars
└── prod/
├── main.tf
├── variables.tf
├── outputs.tf
├── backend.tf
├── terraform.tfvars
└── monitoring.tf # Production-only resources
Each environment is independent but uses shared modules. Changes to modules propagate to all environments, but each environment controls when to apply those changes.
Understanding environment management helps you scale Terraform from a single test environment to a production-ready multi-environment setup. Next, we'll cover remote backends and team collaboration - how to work on Terraform with multiple people safely.
Found an issue?