Ephemeral Test Environments: On-Demand Per-PR Environments
Integration tests that run against a shared staging environment are a liability. Two PRs land simultaneously, one migrates the database schema, and suddenly the other PR's test suite is failing against an incompatible schema it didn't create. Teams work around this with test locks, serialized merge queues, and elaborate state reset scripts — all band-aids on a structural problem.
The structural solution is ephemeral environments: spin up a complete, isolated stack per pull request, run your full test suite against it, then tear it down. Every PR gets its own world.
Why Ephemeral Environments Matter
A shared staging environment conflates two distinct problems: does the code work, and does the deployment process work. Ephemeral environments let you test the former without interference from whoever else is using staging.
The benefits compound:
- No test pollution. Each PR starts from a known clean state. A previous test run can't leave data that breaks yours.
- Parallel testing. Ten PRs can run their full E2E suites simultaneously without coordination overhead.
- Closer to production. You test the actual deployment artifacts — container images, migrations, real network topology — not a simulated version.
- QA access. Product managers and QA engineers can inspect features before merge by visiting the PR environment's URL.
The cost is real — you're running more infrastructure — but modern cloud pricing and spot/preemptible instances make this economical at most scales.
Pattern: Docker Compose Per-PR
For smaller applications, Docker Compose is the fastest path to ephemeral environments in GitHub Actions.
The approach: each workflow run gets its own Docker network (named with the PR number), builds and starts the full stack, runs tests, then cleans up regardless of test outcome.
name: PR Environment
on:
pull_request:
types: [opened, synchronize]
env:
COMPOSE_PROJECT_NAME: pr-${{ github.event.pull_request.number }}
jobs:
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build application image
run: docker build -t myapp:pr-${{ github.event.pull_request.number }} .
- name: Start stack
run: |
docker compose -f docker-compose.test.yml up -d
docker compose -f docker-compose.test.yml run --rm wait-for-services
- name: Run migrations
run: |
docker compose -f docker-compose.test.yml run --rm app \
node scripts/migrate.js
- name: Run E2E tests
run: |
docker compose -f docker-compose.test.yml run --rm test-runner \
npx playwright test --reporter=html
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-pr-${{ github.event.pull_request.number }}
path: playwright-report/
- name: Tear down
if: always()
run: docker compose -f docker-compose.test.yml down -vThe docker-compose.test.yml defines the full application stack:
version: "3.8"
services:
app:
image: myapp:pr-${PR_NUMBER:-local}
environment:
DATABASE_URL: postgres://user:pass@db:5432/testdb
REDIS_URL: redis://cache:6379
NODE_ENV: test
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: testdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 3s
retries: 15
volumes:
- db_data:/var/lib/postgresql/data
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
retries: 10
test-runner:
image: mcr.microsoft.com/playwright:v1.44.0-jammy
working_dir: /workspace
volumes:
- .:/workspace
environment:
BASE_URL: http://app:3000
depends_on:
- app
wait-for-services:
image: alpine/curl:latest
command: >
sh -c "
until curl -sf http://app:3000/health; do
echo 'Waiting for app...'; sleep 2;
done
"
depends_on:
- app
volumes:
db_data:The COMPOSE_PROJECT_NAME environment variable is the key: Docker Compose prefixes all resource names (containers, networks, volumes) with the project name. Setting it to pr-123 means two simultaneous PR runs can't collide.
Namespace-Per-PR in Kubernetes
For teams already running Kubernetes, the natural primitive is a namespace per PR. Each PR gets an isolated Kubernetes namespace with its own deployments, services, and persistent volumes.
A GitHub Actions workflow that creates and tears down namespaces:
name: PR Namespace
on:
pull_request:
types: [opened, synchronize, closed]
env:
NAMESPACE: pr-${{ github.event.pull_request.number }}
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure kubectl
run: |
echo "${{ secrets.KUBECONFIG }}" > kubeconfig.yaml
echo "KUBECONFIG=$(pwd)/kubeconfig.yaml" >> $GITHUB_ENV
- name: Create namespace
run: |
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
kubectl label namespace $NAMESPACE pr=${{ github.event.pull_request.number }} --overwrite
- name: Deploy stack
run: |
helm upgrade --install myapp ./helm/myapp \
--namespace $NAMESPACE \
--set image.tag=pr-${{ github.event.pull_request.number }} \
--set ingress.host=pr-${{ github.event.pull_request.number }}.preview.example.com \
--wait --timeout=5m
- name: Run E2E tests
run: |
BASE_URL=https://pr-${{ github.event.pull_request.number }}.preview.example.com \
npx playwright test
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Configure kubectl
run: |
echo "${{ secrets.KUBECONFIG }}" > kubeconfig.yaml
echo "KUBECONFIG=$(pwd)/kubeconfig.yaml" >> $GITHUB_ENV
- name: Delete namespace
run: kubectl delete namespace pr-${{ github.event.pull_request.number }} --ignore-not-foundNamespace deletion cascades — it removes all deployments, services, secrets, and persistent volume claims in that namespace. The cleanup is thorough and atomic.
Argo CD ApplicationSets
If your team uses Argo CD for GitOps deployments, ApplicationSets provide a declarative way to generate per-PR applications automatically. The Pull Request generator creates an Argo CD Application for every open PR:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: myapp-pr-previews
namespace: argocd
spec:
generators:
- pullRequest:
github:
owner: myorg
repo: myapp
tokenRef:
secretName: github-token
key: token
labels:
- preview
requeueAfterSeconds: 30
template:
metadata:
name: "myapp-pr-{{number}}"
spec:
project: default
source:
repoURL: https://github.com/myorg/myapp
targetRevision: "{{head_sha}}"
path: helm/myapp
helm:
values: |
image:
tag: "pr-{{number}}"
ingress:
host: "pr-{{number}}.preview.example.com"
destination:
server: https://kubernetes.default.svc
namespace: "pr-{{number}}"
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=trueAdd the preview label to any PR and Argo CD automatically deploys it. Remove the label or close the PR and Argo CD cleans it up. No workflow changes needed when you add new PRs.
Okteto: Higher-Level Ephemeral Environments
Okteto provides a managed platform specifically for ephemeral environments. Its configuration is simpler than rolling your own Kubernetes manifests:
# okteto.yml
deploy:
- helm upgrade --install myapp helm/myapp \
--set image.tag=${OKTETO_GIT_COMMIT} \
--set ingress.host=${OKTETO_NAMESPACE}.preview.example.comThe GitHub Actions integration is minimal:
- name: Deploy to Okteto
uses: okteto/pipeline@v1
with:
name: pr-${{ github.event.pull_request.number }}
namespace: myapp-stagingOkteto handles namespace isolation, DNS, TLS certificates, and cleanup. The tradeoff is vendor lock-in and per-environment pricing.
Cost Considerations and Cleanup
Ephemeral environments are not free. Left running overnight, they accumulate cost. A few patterns keep this manageable:
Time-based expiry. Add a TTL label to your namespaces and run a nightly cleanup job:
#!/bin/bash
<span class="hljs-comment"># Delete PR namespaces older than 48 hours with no recent activity
kubectl get namespaces -l <span class="hljs-built_in">pr --no-headers <span class="hljs-pipe">| <span class="hljs-keyword">while <span class="hljs-built_in">read ns rest; <span class="hljs-keyword">do
created=$(kubectl get namespace <span class="hljs-variable">$ns -o jsonpath=<span class="hljs-string">'{.metadata.creationTimestamp}')
age=$(( ($(date +%s) - $(date -d "<span class="hljs-variable">$created" +%s)) / 3600 ))
<span class="hljs-keyword">if [ <span class="hljs-variable">$age -gt 48 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"Deleting $ns (<span class="hljs-variable">${age}h old)"
kubectl delete namespace <span class="hljs-variable">$ns
<span class="hljs-keyword">fi
<span class="hljs-keyword">doneMerge/close triggers. Always clean up on PR close, not just on test completion. GitHub webhooks trigger the closed event reliably.
Right-sized resources. PR environments don't need production-scale replicas. Set resource requests low and use a dedicated node pool with spot/preemptible instances:
# helm/myapp/values-preview.yaml
replicaCount: 1
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
nodeSelector:
workload: preview
tolerations:
- key: preview
operator: Equal
value: "true"
effect: NoScheduleSleep on inactivity. Tools like Okteto and some Kubernetes operators support sleeping an environment when no HTTP traffic has hit it in N minutes, then waking it on the next request.
At modest team sizes (5-20 engineers), ephemeral environments typically cost $50-200/month on spot instances — far less than the time lost to environment conflicts and flaky integration tests on shared staging.