Every engineering team eventually hits the same wall: staging is a shared environment, it’s broken half the time, and no one is sure whose changes are on it. QA is blocked waiting for a stable build. Developers are afraid to push to staging because it’ll break someone else’s review. The staging database has a year’s worth of unreviewed test data in it. A hotfix needs testing but staging is occupied.
Ephemeral environments solve this by flipping the model: instead of one persistent shared staging environment, every pull request gets its own isolated, complete, disposable environment. It’s deployed automatically when the PR is opened, updated with every push, accessible at a unique URL, and destroyed when the PR is merged or closed.
Done well, this collapses the feedback loop dramatically. A designer can review the actual running UI at a shareable link without setting up a local dev environment. QA can test a feature in complete isolation from every other in-flight change. The author gets a real deployment to validate against — not just a local docker compose up. And everyone stops fighting over staging.
This guide covers the architecture patterns, the implementation options from simple to sophisticated, and the operational trade-offs that determine what approach is right for your team.
What an Ephemeral Environment Actually Is
An ephemeral environment is a complete, isolated deployment of your application stack, created on demand and destroyed when no longer needed.
“Complete” is the key word. A preview deployment that only runs the frontend and proxies the API to production isn’t an ephemeral environment — it’s a partial deployment with shared state. True ephemeral environments include:
- Your application (all services)
- A dedicated database (seeded with representative data)
- Any supporting infrastructure (queues, caches, mock external services)
- A unique publicly accessible URL
The lifecycle is:
- Developer opens a PR → environment automatically created
- Every push to the branch → environment automatically updated
- PR merged or closed → environment automatically destroyed
Each environment is identified by something stable and human-readable: the PR number, the branch name, or a short hash. https://pr-247.preview.yourapp.com is more useful than https://d84f3a.preview.yourapp.com.
Naming and URL Routing
Before implementation details, the routing architecture matters. You need:
- A wildcard DNS record:
*.preview.yourapp.com → your ingress IP
- An ingress controller that routes by subdomain to the right environment
- Automatic TLS certificates for each subdomain
With Traefik or nginx-ingress, each ephemeral environment gets an Ingress resource like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: preview-pr-247
namespace: preview-pr-247
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik
rules:
- host: pr-247.preview.yourapp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app
port:
number: 3000
tls:
- hosts:
- pr-247.preview.yourapp.com
secretName: preview-pr-247-tls
|
The wildcard DNS and cert-manager with a DNS challenge issuer handles the TLS automatically — no per-PR manual certificate work needed.
Pattern 1: Namespace-Per-Environment on Kubernetes
The most common and flexible approach for teams already running Kubernetes. Each PR gets its own Kubernetes namespace containing the full application stack.
Why Namespaces
Kubernetes namespaces provide free isolation:
- Network policies can restrict cross-namespace traffic
- Resource quotas prevent a runaway PR from consuming all cluster resources
- RBAC can scope access per environment
- Destroying the environment is
kubectl delete namespace pr-247 — everything inside goes with it
The GitHub Actions Workflow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
|
# .github/workflows/preview.yml
name: Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
NAMESPACE: preview-pr-${{ github.event.pull_request.number }}
HOST: pr-${{ github.event.pull_request.number }}.preview.yourapp.com
jobs:
deploy-preview:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
pull-requests: write # to post the preview URL as a comment
steps:
- uses: actions/checkout@v4
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ env.PR_NUMBER }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Set up kubectl
uses: azure/setup-kubectl@v3
- name: Configure kubeconfig
run: echo "${{ secrets.KUBECONFIG }}" | base64 -d > /tmp/kubeconfig
- name: Deploy preview environment
env:
KUBECONFIG: /tmp/kubeconfig
run: |
# Create namespace if it doesn't exist
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
# Label for easy selection and cleanup
kubectl label namespace $NAMESPACE \
app.kubernetes.io/managed-by=preview \
preview/pr=$PR_NUMBER \
--overwrite
# Apply resource quota to prevent runaway usage
kubectl apply -f - <<EOF
apiVersion: v1
kind: ResourceQuota
metadata:
name: preview-quota
namespace: $NAMESPACE
spec:
hard:
requests.cpu: "2"
requests.memory: 2Gi
limits.cpu: "4"
limits.memory: 4Gi
pods: "20"
EOF
# Deploy the app via Helm (or kustomize — see below)
helm upgrade --install app ./charts/app \
--namespace $NAMESPACE \
--set image.tag=pr-$PR_NUMBER \
--set image.repository=$REGISTRY/$IMAGE_NAME \
--set ingress.host=$HOST \
--set ingress.tls.enabled=true \
--set database.seed=preview \
--set env=preview \
--set replicaCount=1 \
--wait --timeout 5m
- name: Comment preview URL on PR
uses: actions/github-script@v7
with:
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('Preview Environment'));
const body = `## 🚀 Preview Environment
| | |
|--|--|
| **URL** | https://${{ env.HOST }} |
| **Status** | Deployed |
| **Commit** | \`${{ github.sha }}\` |
_Updated: ${new Date().toISOString()}_`;
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
destroy-preview:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Configure kubeconfig
run: echo "${{ secrets.KUBECONFIG }}" | base64 -d > /tmp/kubeconfig
- name: Destroy preview environment
env:
KUBECONFIG: /tmp/kubeconfig
run: kubectl delete namespace $NAMESPACE --ignore-not-found=true
|
Helm Values for Preview Environments
Your Helm chart needs to handle preview-specific configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
# charts/app/values-preview.yaml
replicaCount: 1 # single replica — save resources
image:
pullPolicy: Always # always pull the latest PR image
database:
create: true # provision a dedicated DB per environment
seed: preview # seed with anonymized preview data
size: 1Gi # small — this is ephemeral
redis:
enabled: true
size: 256Mi
ingress:
enabled: true
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
# Disable features that don't make sense in preview
features:
emailSending: false # use a mock/catch-all instead
payments: false # use Stripe test mode
backgroundJobs: true # keep workers — they affect behaviour
|
Database Seeding Strategy
A preview database that’s empty is barely more useful than no database. Seed strategies from simplest to most realistic:
Option 1: Fixture data — static SQL/JSON fixtures committed to the repo, loaded on init.
1
2
|
# In your deployment init container or Helm hook
psql $DATABASE_URL < /seeds/preview-fixtures.sql
|
Option 2: Anonymized production snapshot — take a recent prod dump, scrub PII, compress, store in S3. Load it on every preview deployment.
1
2
3
4
5
6
7
8
9
10
|
# Nightly: dump, anonymize, upload
pg_dump $PROD_DB | \
python anonymize.py | \
gzip | \
aws s3 cp - s3://preview-seeds/latest.sql.gz
# On preview deployment init:
aws s3 cp s3://preview-seeds/latest.sql.gz - | \
gunzip | \
psql $PREVIEW_DB
|
Option 3: Schema-only with factory data — run migrations to get the schema, then generate synthetic data with factories. Slower to set up, always in sync with schema.
1
2
3
4
5
|
# Run migrations
alembic upgrade head # or rails db:migrate, prisma migrate deploy, etc.
# Generate synthetic data
python generate_preview_data.py --users=100 --orders=500
|
Pattern 2: Docker Compose with Traefik Labels
For smaller teams or projects without Kubernetes, ephemeral environments can be built with Docker Compose and Traefik’s dynamic configuration. Each environment is a Compose stack running on a shared server, identified by a project name.
The Architecture
GitHub Actions Runner → SSH → Preview Server
│
Traefik (port 80/443)
│
┌───────────────┼───────────────┐
│ │ │
pr-247 stack pr-248 stack pr-249 stack
(app + db) (app + db) (app + db)
Docker Compose Template
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
|
# docker-compose.preview.yml
# Parameterized with environment variables
services:
app:
image: ghcr.io/yourorg/yourapp:${IMAGE_TAG}
environment:
DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/${DB_NAME}
REDIS_URL: redis://redis:6379
APP_ENV: preview
APP_URL: https://${PREVIEW_HOST}
EMAIL_DRIVER: log # log emails instead of sending
labels:
- "traefik.enable=true"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${PREVIEW_HOST}`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}.tls=true"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}.tls.certresolver=letsencrypt"
- "traefik.http.services.${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=3000"
depends_on:
db:
condition: service_healthy
networks:
- internal
- traefik-public
worker:
image: ghcr.io/yourorg/yourapp:${IMAGE_TAG}
command: worker
environment:
DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/${DB_NAME}
REDIS_URL: redis://redis:6379
depends_on:
- redis
networks:
- internal
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
- ./seeds/preview.sql:/docker-entrypoint-initdb.d/seed.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 5s
retries: 10
networks:
- internal
redis:
image: redis:7-alpine
networks:
- internal
networks:
internal:
traefik-public:
external: true
volumes:
db-data:
|
Deployment Script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
#!/usr/bin/env bash
# deploy-preview.sh — runs on the preview server via SSH
set -euo pipefail
PR_NUMBER="${1}"
IMAGE_TAG="${2}"
ACTION="${3:-deploy}" # deploy | destroy
PROJECT_NAME="preview-pr-${PR_NUMBER}"
PREVIEW_HOST="pr-${PR_NUMBER}.preview.yourapp.com"
DB_NAME="preview_pr_${PR_NUMBER}"
DB_PASSWORD=$(openssl rand -base64 20 | tr -d '=/+')
DEPLOY_DIR="/opt/previews/${PROJECT_NAME}"
if [[ "$ACTION" == "destroy" ]]; then
echo "Destroying preview for PR ${PR_NUMBER}..."
cd "$DEPLOY_DIR"
docker compose -p "$PROJECT_NAME" down -v --remove-orphans
rm -rf "$DEPLOY_DIR"
echo "Destroyed."
exit 0
fi
# Create deploy directory
mkdir -p "$DEPLOY_DIR"
cp /opt/previews/docker-compose.preview.yml "$DEPLOY_DIR/"
# Write environment file (not committed — generated per deployment)
cat > "$DEPLOY_DIR/.env" <<EOF
COMPOSE_PROJECT_NAME=${PROJECT_NAME}
IMAGE_TAG=${IMAGE_TAG}
PREVIEW_HOST=${PREVIEW_HOST}
DB_NAME=${DB_NAME}
DB_PASSWORD=${DB_PASSWORD}
EOF
# Pull the new image
docker pull "ghcr.io/yourorg/yourapp:${IMAGE_TAG}"
# Deploy (or update)
cd "$DEPLOY_DIR"
docker compose -p "$PROJECT_NAME" up -d --remove-orphans --wait
echo "Preview deployed: https://${PREVIEW_HOST}"
|
1
2
3
4
5
6
7
8
9
10
11
12
|
# In GitHub Actions — call the script via SSH
- name: Deploy preview
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PREVIEW_SERVER_HOST }}
username: deploy
key: ${{ secrets.PREVIEW_SERVER_SSH_KEY }}
script: |
/opt/previews/deploy-preview.sh \
${{ github.event.pull_request.number }} \
pr-${{ github.event.pull_request.number }} \
deploy
|
Cleanup: Don’t Leak Environments
Environments must be destroyed reliably. Two safety nets beyond the PR closed trigger:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
# 1. Destroy on PR close (primary)
- name: Destroy preview
if: github.event.action == 'closed'
...
# 2. Scheduled cleanup of environments older than 7 days
# .github/workflows/cleanup-previews.yml
on:
schedule:
- cron: '0 3 * * *' # nightly at 3am
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Clean up stale preview namespaces
env:
KUBECONFIG: ...
run: |
# Delete namespaces with preview label older than 7 days
kubectl get namespaces -l app.kubernetes.io/managed-by=preview \
-o json | \
jq -r '.items[] |
select(
(now - (.metadata.creationTimestamp | fromdateiso8601)) > 604800
) | .metadata.name' | \
xargs -r kubectl delete namespace
|
Pattern 3: Managed Preview Services
If you don’t want to manage the infrastructure yourself, several platforms provide ephemeral environments as a service:
Vercel / Netlify (Frontend)
For frontend applications, Vercel and Netlify provide preview deployments out of the box with zero configuration — every PR gets a unique URL automatically. This works for static sites and serverless functions but doesn’t help with full-stack applications that need a backend and database.
Railway / Render
Railway and Render both support preview environments for full-stack applications. Railway creates a copy of your services and databases from a template; Render creates “preview environments” that spin up all services from a render.yaml configuration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
# render.yaml
services:
- type: web
name: app
env: node
buildCommand: npm run build
startCommand: npm start
envVars:
- key: DATABASE_URL
fromDatabase:
name: app-db
property: connectionString
databases:
- name: app-db
databaseName: app
plan: starter
previewPlan: starter
|
Argo CD ApplicationSets (GitOps)
For teams using GitOps with Argo CD, ApplicationSets with the Pull Request generator automatically create Argo CD Applications for each open PR:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: preview-environments
namespace: argocd
spec:
generators:
- pullRequest:
github:
owner: your-org
repo: your-app
tokenRef:
secretName: github-token
key: token
labels:
- preview # only PRs with this label get a preview
requeueAfterSeconds: 30
template:
metadata:
name: 'preview-{{branch}}-{{number}}'
spec:
project: previews
source:
repoURL: https://github.com/your-org/your-app
targetRevision: '{{head_sha}}'
path: charts/app
helm:
values: |
image:
tag: pr-{{number}}
ingress:
host: pr-{{number}}.preview.yourapp.com
database:
seed: preview
destination:
server: https://kubernetes.default.svc
namespace: 'preview-{{number}}'
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
|
With this setup, labeling a PR with preview triggers Argo CD to deploy it. Removing the label or closing the PR automatically destroys the environment.
Handling Databases in Ephemeral Environments
Databases are the hardest part. Options from simplest to most sophisticated:
Option 1: Shared Schema, Isolated Prefix
Use one database server but prefix all table names or use a separate schema per environment. Simple, low resource overhead, but leaks if your app doesn’t support prefixing.
1
2
3
4
|
-- Each preview environment gets its own PostgreSQL schema
CREATE SCHEMA preview_pr_247;
SET search_path = preview_pr_247;
-- Now all tables are created in this schema
|
Option 2: Dedicated Database Container Per Environment
Each Compose stack or Kubernetes namespace gets its own database pod. Fully isolated, straightforward, but resource-intensive at scale.
Option 3: Database Branching
Purpose-built database platforms support copy-on-write database branching — instantaneous copies of a database that diverge independently:
Neon (serverless Postgres) has first-class branch support:
1
2
3
4
5
6
7
8
|
# Create a branch for PR 247 from the main branch
neon branches create --name pr-247 --parent main
# Get the connection string for this branch
neon connection-string pr-247
# Delete the branch when the PR closes
neon branches delete pr-247
|
PlanetScale (MySQL) has similar branching semantics. Supabase supports database branching in their managed platform. These services integrate well with preview environments because a branch is instantaneous regardless of database size — it’s copy-on-write at the storage layer, not a physical copy.
Option 4: Read Replica + Write Sandbox
For very large databases where even a partial copy is impractical, use the production database (or a read replica) for reads, and capture writes in a sandbox layer that intercepts write queries. This is complex to implement but some companies with very large datasets use this approach.
Resource Management and Cost Control
Ephemeral environments can get expensive if uncontrolled. Key levers:
Kubernetes Resource Quotas
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
apiVersion: v1
kind: ResourceQuota
metadata:
name: preview-quota
namespace: preview-pr-247
spec:
hard:
requests.cpu: "1"
requests.memory: 1Gi
limits.cpu: "2"
limits.memory: 2Gi
pods: "10"
persistentvolumeclaims: "3"
requests.storage: 5Gi
|
Auto-Sleep / Scale-to-Zero
For environments that aren’t actively being used, scaling to zero while keeping the namespace alive saves resources without destroying the environment:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
# Knative Serving — automatic scale to zero after idle timeout
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: app
namespace: preview-pr-247
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/min-scale: "0" # scale to zero when idle
autoscaling.knative.dev/max-scale: "1"
autoscaling.knative.dev/target: "10"
|
Alternatively, a simple cron job that scales preview Deployments to 0 replicas at midnight and back to 1 at business hours:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
# CronJob: sleep preview environments at night
apiVersion: batch/v1
kind: CronJob
metadata:
name: sleep-previews
namespace: kube-system
spec:
schedule: "0 22 * * *" # 10pm
jobTemplate:
spec:
template:
spec:
containers:
- name: kubectl
image: bitnami/kubectl
command:
- sh
- -c
- |
kubectl get namespaces -l app.kubernetes.io/managed-by=preview \
-o name | \
xargs -I{} kubectl scale deployment --all --replicas=0 -n {}
|
Limit the Number of Active Preview Environments
1
2
3
4
5
|
# In your GitHub Actions workflow — close stale PRs or skip preview
# for draft PRs to avoid runaway environment proliferation
- name: Skip preview for draft PRs
if: github.event.pull_request.draft == true
run: echo "Skipping preview for draft PR"
|
Testing in Ephemeral Environments
The real payoff of ephemeral environments is running integration and end-to-end tests against a real deployment:
Post-Deploy Smoke Tests
1
2
3
4
5
6
7
8
9
10
11
12
|
# After deployment step in GitHub Actions
- name: Run smoke tests against preview
run: |
# Wait for environment to be healthy
timeout 120 bash -c \
'until curl -sf https://${{ env.HOST }}/health; do sleep 5; done'
# Run smoke test suite
npx playwright test \
--project=chromium \
--base-url=https://${{ env.HOST }} \
tests/smoke/
|
Visual Regression Testing
1
2
3
4
5
6
7
8
|
- name: Visual regression tests
uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_TOKEN }}
buildScriptName: build-storybook
# Or run against the full preview URL:
autoAcceptChanges: false
exitOnceUploaded: false
|
Sharing the Environment URL in PRs
Beyond just the URL, a rich PR comment that includes:
- Link to the preview
- Direct links to key flows (login page, the feature being reviewed)
- Environment status and last deploy time
- Link to the deployment logs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// In GitHub Actions script step
const body = `## Preview Environment — Ready ✅
**[→ Open Preview](https://${host})**
| Link | Description |
|------|-------------|
| [Home](https://${host}/) | Landing page |
| [Login](https://${host}/login) | Auth flow |
| [${featurePath}](https://${host}${featurePath}) | This PR's feature |
**Build:** \`${sha.slice(0, 7)}\`
**Deployed:** ${new Date().toLocaleString('en-US', {timeZone: 'UTC'})} UTC
**Logs:** [GitHub Actions](${runUrl})
---
_Environment will be destroyed when this PR is merged or closed._`;
|
Security Considerations
Ephemeral environments introduce a real attack surface:
Secrets management: Preview environments need database credentials, API keys, and service tokens. Never put real production secrets in preview environments. Use:
- Separate preview-tier credentials (Stripe test mode, SendGrid sandbox)
- A dedicated secrets store namespace with limited permissions
- Sealed Secrets or External Secrets Operator to inject preview-specific secrets per namespace
Network exposure: Preview environments are reachable from the internet. Apply:
- Firewall rules that limit inbound to HTTPS only
- Authentication on the preview itself (HTTP basic auth via Traefik middleware)
- IP allowlisting if your team all works from known IPs or a VPN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# Traefik middleware: protect all preview environments with basic auth
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: preview-auth
namespace: traefik
spec:
basicAuth:
secret: preview-credentials # Kubernetes secret with htpasswd format
realm: "Preview Environment"
removeHeader: true
# Apply to all preview ingresses
annotations:
traefik.ingress.kubernetes.io/router.middlewares: traefik-preview-auth@kubernetescrd
|
Image scanning: The PR image being deployed should be scanned before deployment:
1
2
3
4
5
6
7
|
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ env.PR_NUMBER }}
exit-code: '1'
severity: 'CRITICAL'
ignore-unfixed: true
|
When Ephemeral Environments Are Overkill
Ephemeral environments aren’t always the right investment:
- Solo developers or very small teams — the overhead of setting up the infrastructure often exceeds the value when you’re the only reviewer
- Simple static sites — Vercel/Netlify handle this for free with zero setup
- Very fast local dev cycle — if the full stack runs in
docker compose up in 30 seconds and everyone on the team works that way, shared staging may be sufficient
- Regulated environments — some compliance frameworks complicate spinning up and destroying environments freely
The sweet spot is teams of 3–20 engineers with a reasonably complex stack where the shared staging environment is a regular source of friction. At that point, the investment in ephemeral environments pays back in the first week of use.
Getting Started: The Minimal Viable Preview
The most common mistake is over-engineering the first version. The simplest possible ephemeral environment that provides real value:
- Build the image on every PR push (you probably already do this)
- Deploy it to a single Kubernetes namespace using
helm upgrade --install with the PR number
- Post the URL as a PR comment
- Delete the namespace on PR close
That’s it. No database branching, no scale-to-zero, no visual regression tests. Add those once the basic loop is working and you’ve seen the value first-hand. The infrastructure to do this is 80 lines of GitHub Actions YAML and a Helm chart you likely already have.
Start simple. The compounding value comes from getting it running fast and getting your team into the habit of reviewing real deployments — not from building a perfect preview environment system that takes six weeks to finish.
Comments