Ephemeral Test Environments: On-Demand Per-PR Environments

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 -v

The 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-found

Namespace 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=true

Add 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.com

The GitHub Actions integration is minimal:

      - name: Deploy to Okteto
        uses: okteto/pipeline@v1
        with:
          name: pr-${{ github.event.pull_request.number }}
          namespace: myapp-staging

Okteto 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">done

Merge/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: NoSchedule

Sleep 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.

Read more