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_requestopen, 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 -
curlandjqfor 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.
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.
Related
- Spinning Up Per-Pull-Request Mock Stacks — the full namespacing, dynamic-port, and PR-comment mechanics
- Resetting Mock State Between Test Runs — reclaiming a reused preview stack’s data between E2E passes
- Running Mock Servers in CI Pipelines — the in-job mock stack these previews are promoted from
- Dockerized Mock Environments — the Compose files and images each preview stack is built on
← Back to CI/CD & Test Integration