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.
What a preview stack is actually for
It is worth being precise about the value, because per-branch stacks are not free and teams that cannot articulate what they are buying tend to let them rot.
The first thing a preview stack buys is review without a checkout. A designer, a product manager or an engineer from another team can open a URL and use the change. That removes an entire category of review comment — “I cannot tell from the diff what this looks like” — and it removes it for the people least able to run the project locally.
The second is isolation from other work in flight. A shared staging environment holds whatever was deployed last, which on a busy team is somebody else’s half-finished feature. Two changes that each work alone and conflict together are discovered at merge, which is the most expensive moment to discover them. A per-branch stack removes the shared surface entirely.
The third, and the one specific to mocking, is a deterministic backend per branch. A preview environment pointed at shared staging inherits staging’s data, which changes under it. A preview environment with its own mock stack has exactly the data its fixtures define, so a reviewer who sees a bug can be certain the bug is in the change rather than in whatever state staging happens to be in.
Against that sit three real costs. Every stack consumes ports, disk and memory on a shared runner. Every stack needs a lifecycle, including the failure paths where a job is cancelled and the teardown never runs. And every stack needs its data seeded, which means the fixtures have to be good enough to demonstrate the feature rather than merely good enough to make the page render.
The teams that get value here treat the third cost as the interesting one. A preview stack seeded with three generic records demonstrates that the page loads; a stack seeded with a realistic dataset — including the empty state, the long name and the failure case — lets a reviewer actually exercise the change. The stack is the delivery mechanism; the fixtures are what makes it worth delivering.
A reasonable test of whether the investment is paying: count how many review comments in the last month were resolved by someone opening a preview URL. If the answer is none, the stacks are infrastructure nobody is using, and the fixtures are usually why.
Sizing the investment
Per-branch environments are frequently built to a standard nobody asked for and then abandoned because the running cost exceeded the value. It is worth sizing the investment against what the team actually needs.
The smallest useful version is a static build of the frontend deployed to a preview URL, with a mock stack seeded from committed fixtures. No database, no backend services, no per-branch secrets. It answers “what does this change look like and can I click through it”, which is the question most review comments are actually asking. It is also cheap enough to build in a day.
The next step up adds per-branch state: a scenario primed to a mid-flow position, or an overlay fixture shipped with the branch. This is worth adding when reviewers keep asking “how do I get to the state where this matters”, and it costs a small convention rather than new infrastructure.
The expensive version — real backend services, a database per branch, seeded migrations — is worth it only for changes that genuinely cannot be demonstrated against a mock. That is a smaller set than it appears: most frontend and API-surface changes are fully demonstrable against fixtures, and the ones that are not usually involve a data migration, which deserves its own dedicated environment rather than one per branch.
The failure pattern is building the expensive version first, discovering that it takes eleven minutes to provision and breaks weekly, and concluding that preview environments do not work. The cheap version, provisioned in under a minute and reliable, gets used every day.
A useful rule: if a preview stack takes longer to provision than a reviewer is willing to wait, it will not be used, and everything spent on it beyond that point is waste. Optimise for time-to-URL before optimising for fidelity.
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