Why ephemeral preview environments matter
You're about to merge a feature branch, but you need confidence that the infrastructure changes work alongside your code. Ephemeral preview environments: best practices explained is the answer—creating temporary, isolated infrastructure per pull request, then destroying it when the PR closes. This approach lets teams validate database migrations, environment variables, and resource configuration before production, without the cost and complexity of long-lived staging environments.
In this article, we'll walk through how to implement ephemeral environments using Terraform, covering the patterns that work at scale across e-commerce, SaaS, and trading platforms.
Workspaces or prefixes per PR
The foundation of ephemeral environments is isolation. Each pull request needs its own isolated state and resources. You have two primary approaches: Terraform workspaces and resource naming prefixes.
Workspaces are Terraform's built-in isolation mechanism. They maintain separate state files for the same configuration, letting you run terraform apply in different workspaces without colliding.
terraform workspace new pr-1234
terraform workspace select pr-1234
terraform apply
Your CI/CD pipeline creates a workspace named after the PR number, applies the configuration, and destroys it when the PR closes or merges. The advantage: minimal code changes. The downside: you're applying the exact same Terraform to different workspaces, which works for many cases but becomes brittle when you need environment-specific overrides.
Prefixes give you finer control. Instead of workspaces, you parameterize resource names with a prefix variable:
variable "environment_prefix" {
type = string
default = "dev"
}
resource "aws_ecs_cluster" "app" {
name = "${var.environment_prefix}-cluster"
}
resource "aws_rds_cluster" "db" {
cluster_identifier = "${var.environment_prefix}-database"
# ...
}
In your CI/CD, you pass the prefix as a variable:
terraform apply -var="environment_prefix=pr-1234"
This pattern is more explicit and scales better when you have environment-specific settings (different instance types, log retention, backup policies). Many teams at scale use prefixes because they're easier to reason about and debug.
A hybrid approach works too: use workspaces for state isolation and prefixes for naming. This gives you the organizational clarity of workspaces with the naming control of prefixes.
Small, cheap resource sizes
A preview environment doesn't need production capacity. If your staging environment runs on t3.large RDS instances and Kubernetes with 10 nodes, your PR preview should run on a fraction of that.
Define environment-specific variables that scale down compute, memory, and storage for preview environments:
variable "environment_type" {
type = string
default = "production"
}
variable "instance_type_map" {
type = map(string)
default = {
production = "t3.xlarge"
staging = "t3.large"
preview = "t3.micro"
}
}
variable "rds_allocated_storage_map" {
type = map(number)
default = {
production = 500
staging = 100
preview = 20
}
}
resource "aws_db_instance" "main" {
instance_class = var.instance_type_map[var.environment_type]
allocated_storage = var.rds_allocated_storage_map[var.environment_type]
backup_retention_period = var.environment_type == "preview" ? 0 : 7
skip_final_snapshot = var.environment_type == "preview"
}
For Kubernetes clusters, disable autoscaling and set a single small node for preview:
resource "aws_eks_node_group" "workers" {
desired_size = var.environment_type == "preview" ? 1 : 3
min_size = var.environment_type == "preview" ? 1 : 2
max_size = var.environment_type == "preview" ? 1 : 10
instance_types = [var.instance_type_map[var.environment_type]]
}
A real savings example: if a production RDS costs $400/month and staging costs $100/month, a preview environment on a micro instance with no backups costs $15–$25/month. Across 20 concurrent PRs, you're still under $500/month for all preview databases combined—a rounding error compared to the value of catching bugs early.
Destroy on PR close
The "ephemeral" part is crucial. When a PR closes (whether merged or abandoned), the preview environment must be deleted. This requires CI/CD automation.
In GitHub Actions, listen for the pull_request event with action closed:
name: Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: hashicorp/setup-terraform@v2
- name: Init Terraform
run: terraform init
- name: Plan/Apply or Destroy
env:
TF_VAR_environment_prefix: pr-${{ github.event.number }}
TF_VAR_environment_type: preview
run: |
if [[ "${{ github.event.action }}" == "closed" ]]; then
terraform destroy -auto-approve
else
terraform apply -auto-approve
fi
The key detail: github.event.number gives you the PR number, which matches your resource naming prefix. When the PR closes, Terraform destroys all resources tagged or named with that prefix.
For GitLab CI, use rules to trigger destruction on merge request close:
destroy_preview:
script:
- terraform init
- terraform destroy -auto-approve
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_ACTION == "close"'
One critical safeguard: use separate AWS IAM credentials for preview environments. If a developer's laptop is compromised and their credentials leak, the attacker can't destroy production infrastructure—they can only touch preview resources. This is a hard requirement for any automation touching real cloud accounts.
Share expensive resources like databases
Not everything should be ephemeral. Databases are expensive to create and often slow to initialize. A better pattern: create a shared database per team or per feature branch category, with separate schemas or namespaces for each PR.
For RDS, create one database per environment but use separate schemas:
resource "aws_db_instance" "shared" {
identifier = "shared-preview-db"
instance_class = "t3.small"
allocated_storage = 50
# ...
}
# In your application code or Terraform, create a schema per PR:
resource "null_resource" "create_schema" {
provisioner "local-exec" {
command = <<-EOT
PGPASSWORD="${aws_db_instance.shared.password}" psql \
-h ${aws_db_instance.shared.endpoint} \
-U admin \
-d postgres \
-c "CREATE SCHEMA IF NOT EXISTS pr_${{ github.event.number }}"
EOT
}
}
For DynamoDB or similar NoSQL, prefix table names but keep the tables shared:
resource "aws_dynamodb_table" "preview_data" {
name = "preview-data-${var.environment_prefix}"
billing_mode = "PAY_PER_REQUEST" # On-demand, perfect for ephemeral
hash_key = "id"
attribute {
name = "id"
type = "S"
}
}
Redis is another good shared candidate. One Redis instance per environment, with key prefixes isolating PR data:
resource "aws_elasticache_cluster" "preview_cache" {
cluster_id = "preview-cache"
engine = "redis"
node_type = "cache.t3.micro"
num_cache_nodes = 1
parameter_group_name = "default.redis7"
# ...
}
Your application uses keys like pr-1234:session:user123 instead of bare session:user123. On PR close, you run a cleanup job that deletes all keys with the PR prefix.
This pattern cuts infrastructure costs dramatically. Five concurrent preview environments no longer need five RDS instances; they share one instance with isolated schemas. You pay for storage once, not five times.
Common mistakes
Forgetting to pass credentials to CI/CD. Your GitHub Actions or GitLab CI job must have AWS (or cloud provider) credentials to authenticate. Use OIDC federation where possible—it's more secure than storing long-lived access keys. Never commit credentials to your repository.
Not cleaning up orphaned resources. If a CI/CD job crashes mid-destroy, resources linger. Add a scheduled job to delete resources older than N hours that match the PR prefix pattern. This prevents surprise bills.
Applying production security groups or IAM roles to preview. Preview environments should have minimal permissions. Don't copy your production database security group; create a separate one that only allows traffic from preview ECS clusters. Blast radius matters.
Ignoring backup and snapshot cleanup. RDS snapshots and EBS volume snapshots can accumulate. Set skip_final_snapshot = true and backup_retention_period = 0 for preview databases to avoid surprise costs.
Creating ephemeral environments for every commit instead of every PR. This scales poorly. Use a single environment per PR, redeploy on new commits to that PR, and destroy once.
Key takeaways
- Isolate each preview environment using Terraform workspaces or resource naming prefixes; prefixes scale better for complex setups.
- Right-size preview resources aggressively—use micro instances, minimal storage, and disabled backups to keep costs low.
- Automate destruction on PR close using CI/CD event listeners; use
closedactions in GitHub and merge_request close in GitLab. - Share expensive, slow-to-initialize resources like databases across PRs using schemas, separate DynamoDB tables, or key prefixes.
- Separate credentials for preview and production; leak of preview keys should never threaten production.
- Monitor and clean up orphaned resources with scheduled jobs; CI/CD failures can leave dangling infrastructure.






Comments
Loading comments…