Ephemeral Preview Environments

This page covers building disposable mock stacks that live for the lifetime of a pull request: one isolated environment per PR, reachable at its own URL, torn down automatically when the PR closes.

Prerequisites

  • Docker with the Compose plugin (v2.20+) on the runner or preview host
  • A mock stack expressed as a Compose file — typically a dockerized mock environment with the app plus a mock service
  • A CI provider that fires on pull_request open, sync, and close events (examples here use GitHub Actions)
  • Write permission for the workflow to comment on pull requests (pull-requests: write)
  • A base wildcard hostname you control (e.g. *.preview.acme.dev) or a tunnel for external reviewer access
  • curl and jq for verification

Why per-PR stacks

A single shared mock environment forces every open pull request to queue behind the others: one branch’s data mutations corrupt another’s E2E run, and two PRs cannot exercise conflicting mock behaviour at the same time. Giving each PR its own stack removes the contention entirely — each environment is isolated, reproducible from the branch, and disposable.

The lifecycle below is what the rest of this page automates: a PR opens, a uniquely-named stack comes up, E2E runs against its URL, and the whole stack is reclaimed when the PR closes.

Ephemeral preview environment lifecycle A pull request opens, triggering a unique namespaced stack containing a mock service and the application. End-to-end tests run against the preview URL. When the pull request closes, the stack is torn down and its resources reclaimed. PR opened #PR number Namespaced stack pr-142_* mock service application E2E vs preview URL PR closed teardown preview URL posted as PR comment

Phase 1 — A namespaced Compose project per PR

The mechanism that keeps stacks from colliding is COMPOSE_PROJECT_NAME. Compose prefixes every container, network, and volume with the project name, so two stacks with different names are fully isolated even on the same host.

# docker-compose.preview.yml
services:
  mock-api:
    image: ghcr.io/acme/mock-api:latest
    environment:
      MOCK_SEED: "${MOCK_SEED:-42}"
    expose:
      - "8080"
    ports:
      - "8080"          # host port left blank => Docker assigns an ephemeral one
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
      interval: 5s
      timeout: 3s
      retries: 5

  app:
    image: ghcr.io/acme/web:${GIT_SHA:-latest}
    environment:
      API_BASE_URL: "http://mock-api:8080"
    ports:
      - "3000"
    depends_on:
      mock-api:
        condition: service_healthy

Derive the project name from the PR number and bring the stack up:

export COMPOSE_PROJECT_NAME="pr-${PR_NUMBER}"
docker compose -f docker-compose.preview.yml up -d --wait

The --wait flag blocks until every service with a healthcheck reports healthy, folding the readiness gate from mock lifecycle management directly into the up command.


Phase 2 — Dynamic ports and URL injection

Because the Compose file leaves host ports blank, Docker assigns a free one per stack. Read it back and construct the preview URL the E2E suite will target:

# scripts/resolve-preview-url.sh
#!/usr/bin/env bash
set -euo pipefail
export COMPOSE_PROJECT_NAME="pr-${PR_NUMBER}"

APP_PORT=$(docker compose -f docker-compose.preview.yml port app 3000 | cut -d: -f2)
PREVIEW_URL="http://127.0.0.1:${APP_PORT}"

echo "PREVIEW_URL=${PREVIEW_URL}" >> "$GITHUB_ENV"
echo "Resolved preview URL: ${PREVIEW_URL}"

Inject that URL into the end-to-end runner so tests hit the preview instead of a hard-coded host. For Playwright:

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.PREVIEW_URL ?? 'http://localhost:3000',
  },
  reporter: [['list'], ['html', { open: 'never' }]],
});

Reading baseURL from the environment keeps the application’s network layer abstraction intact — the same build runs against a preview stack in CI and a local mock during development.


Phase 3 — Publishing the URL and reclaiming the stack

A preview is only useful if reviewers can find it, and only safe if it disappears. Post the URL as a PR comment on open, and tear the stack down on close. This single workflow handles both events:

# .github/workflows/preview.yml
name: preview
on:
  pull_request:
    types: [opened, synchronize, closed]

permissions:
  pull-requests: write
  contents: read

jobs:
  up:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    env:
      PR_NUMBER: ${{ github.event.number }}
      GIT_SHA: ${{ github.sha }}
    steps:
      - uses: actions/checkout@v4
      - name: Bring up preview stack
        run: |
          export COMPOSE_PROJECT_NAME="pr-${PR_NUMBER}"
          docker compose -f docker-compose.preview.yml up -d --wait
      - name: Resolve preview URL
        run: bash scripts/resolve-preview-url.sh
      - name: Comment the preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = process.env.PREVIEW_URL;
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `Preview environment ready: ${url}`,
            });

  down:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    env:
      PR_NUMBER: ${{ github.event.number }}
    steps:
      - uses: actions/checkout@v4
      - name: Tear down preview stack
        run: |
          export COMPOSE_PROJECT_NAME="pr-${PR_NUMBER}"
          docker compose -f docker-compose.preview.yml down --volumes --remove-orphans

The full, hardened version of the up-side — including a comment that updates in place instead of stacking a new one per push — is in Spinning Up Per-Pull-Request Mock Stacks. When a preview stack is reused across several E2E runs, resetting its data between runs matters as much as tearing it down; that is covered in Resetting Mock State Between Test Runs.


Verification steps

Run these after the up job to confirm the stack is isolated and reachable.

  • The stack is named for the PR:
    docker compose ls | grep "pr-${PR_NUMBER}"
    # Expected: one row for the project in "running" state
  • The mock inside the stack is healthy:
    MOCK_PORT=$(docker compose -f docker-compose.preview.yml port mock-api 8080 | cut -d: -f2)
    curl -fsS "http://127.0.0.1:${MOCK_PORT}/health" | jq -e '.status == "ok"'
    # Expected: exits 0
  • The preview URL serves the app:
    curl -fsS -o /dev/null -w "%{http_code}\n" "${PREVIEW_URL}"
    # Expected: 200
  • Two open PRs do not share ports:
    docker ps --format '{{.Names}} {{.Ports}}' | grep -E 'pr-[0-9]+'
    # Expected: distinct host ports per project

Troubleshooting

Error: bind: address already in use on the second concurrent PR

A fixed host port was pinned in the Compose file, so the second stack cannot bind it. Replace "8080:8080" with a bare "8080" (or "0:8080") so Docker assigns an ephemeral host port per stack, then read it back with docker compose port. Never hard-code a host port in a per-PR stack.

Orphaned stacks accumulate after cancelled jobs

If the closed event is missed — a force-push race, a cancelled workflow — the down job never runs and the stack leaks. Add a scheduled sweep that reconciles running projects against open PRs:

# scripts/sweep-orphans.sh — run on a cron schedule
#!/usr/bin/env bash
set -euo pipefail
OPEN=$(gh pr list --state open --json number --jq '.[].number')
for project in $(docker compose ls --format json | jq -r '.[].Name' | grep '^pr-'); do
  num="${project#pr-}"
  if ! grep -qx "$num" <<< "$OPEN"; then
    echo "Reclaiming orphaned stack ${project}"
    COMPOSE_PROJECT_NAME="$project" docker compose -f docker-compose.preview.yml down --volumes
  fi
done

E2E hits ECONNREFUSED even though the app container is up

The app started before the mock was ready and cached a failed connection, or PREVIEW_URL still points at the default. Confirm the depends_on ... condition: service_healthy gate is present so the app waits for the mock, and echo PREVIEW_URL in the E2E step to be sure the resolved port was exported.

Reviewer cannot reach 127.0.0.1 preview URLs

A loopback URL only works on the runner. For external review, front the stack with a tunnel or a wildcard-DNS reverse proxy that maps pr-142.preview.acme.dev to the assigned port, and comment that public hostname instead of the loopback address.


When to advance

The preview system is solid when:

  • Every open PR has exactly one running stack, named pr-<number>, on its own ports.
  • The preview URL is posted (and updated in place) on the PR within a minute of opening.
  • Closing a PR reliably removes its stack, volumes, and networks, verified by docker compose ls.
  • A scheduled sweep confirms zero orphaned stacks for closed PRs.

Once these hold, deepen each half independently: the per-PR stack mechanics for richer namespacing, and state reset between runs for reusing a long-lived preview across many E2E passes.


FAQ

How is an ephemeral preview stack different from a normal CI mock?

A normal CI mock lives and dies inside one job — it starts, serves that job’s tests, and is torn down when the job ends. An ephemeral preview stack outlives the job: it stays up on a stable, namespaced URL so reviewers and repeated E2E runs can hit it for as long as the pull request is open, and it is reclaimed only when the PR closes. The lifecycle is bound to the pull request, not the job, which is why teardown is driven by the closed event rather than an if: always() step.

How do I avoid port exhaustion when many PRs are open at once?

Do not pin host ports. Let Docker assign an ephemeral host port to each stack by leaving the host side of the port mapping blank, then read it back with docker compose port and route to it by a namespaced hostname. Pinning a fixed port means the second concurrent PR fails to bind and its stack never starts; dynamic allocation scales to as many stacks as the host has free ports, and a reverse proxy hides the shifting port numbers behind stable per-PR hostnames.

What cleans up a stack if the teardown job never runs?

Because every stack carries a COMPOSE_PROJECT_NAME derived from the PR number, a scheduled sweep can list running projects, cross-reference the set of open PRs, and remove any stack whose PR is already closed. This backstops the on-close teardown so a cancelled workflow, a force-push race, or a crashed runner never leaks a stack forever. The sweep script in the troubleshooting section runs on a cron schedule and reconciles the two lists.


← Back to CI/CD & Test Integration