Spinning Up Per-Pull-Request Mock Stacks
You want each pull request to boot its own mock-backed environment, but two open PRs collide on container names and host ports, and reviewers have no link to click. The missing pieces are a unique namespace per PR, a host port the operating system hands out instead of one you pin, and a comment that carries the resulting URL back to the PR.
Why collisions happen and how namespacing fixes them
Docker Compose derives every container, network, and volume name from the project name, which defaults to the working directory. Run the same Compose file for two PRs from the same checkout directory and both stacks claim web-mock-api-1, the same bridge network, and — if you pinned 8080:8080 — the same host port. The second up either adopts the first stack’s containers or fails to bind.
Setting COMPOSE_PROJECT_NAME to something derived from the PR number makes the two stacks disjoint: pr-142_mock-api-1 and pr-143_mock-api-1 never touch. Leaving the host port blank makes the kernel assign a free one to each. Together they let arbitrarily many PR stacks coexist on one host — the isolation guarantee behind Ephemeral Preview Environments, built on the same dockerized mock environment images.
Solution
1. Write a Compose file with no pinned host ports
# docker-compose.pr.yml
services:
mock-api:
image: ghcr.io/acme/mock-api:latest
environment:
MOCK_SEED: "${PR_NUMBER:-0}" # seed varies per PR for distinct data
ports:
- "8080" # dynamic host port
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
Note MOCK_SEED is set to the PR number, so each preview gets a distinct but reproducible dataset via deterministic seed management — reviewers on different PRs see different data, but re-running the same PR reproduces it exactly.
2. Bring the stack up under a per-PR namespace
# scripts/pr-up.sh
#!/usr/bin/env bash
set -euo pipefail
: "${PR_NUMBER:?PR_NUMBER is required}"
export COMPOSE_PROJECT_NAME="pr-${PR_NUMBER}"
docker compose -f docker-compose.pr.yml up -d --wait
APP_PORT=$(docker compose -f docker-compose.pr.yml port app 3000 | cut -d: -f2)
PREVIEW_URL="http://127.0.0.1:${APP_PORT}"
echo "PREVIEW_URL=${PREVIEW_URL}" >> "${GITHUB_ENV:-/dev/stdout}"
echo "Stack pr-${PR_NUMBER} up at ${PREVIEW_URL}"
3. Post the URL as an updating PR comment
Creating a fresh comment on every push spams the thread. Instead, find the workflow’s previous comment by a hidden marker and edit it in place:
# .github/workflows/pr-preview.yml
name: pr-preview
on:
pull_request:
types: [opened, synchronize]
permissions:
pull-requests: write
contents: read
jobs:
preview:
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{ github.event.number }}
GIT_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
- name: Bring up the PR stack
run: bash scripts/pr-up.sh
- name: Upsert preview comment
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- preview-url -->';
const body = `${marker}\nPreview for \`${process.env.GIT_SHA.slice(0,7)}\`: ${process.env.PREVIEW_URL}`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
The hidden <!-- preview-url --> marker is invisible in the rendered comment but lets the next run find and overwrite the same comment, so the PR always shows exactly one preview link pointing at the latest commit.
Verification
Confirm two PR numbers produce two isolated stacks on distinct ports with one command:
PR_NUMBER=142 bash scripts/pr-up.sh && PR_NUMBER=143 bash scripts/pr-up.sh && \
docker ps --filter "name=pr-14" --format '{{.Names}}\t{{.Ports}}'
Expected — four containers across two projects, each app on a different host port:
pr-142-app-1 0.0.0.0:49180->3000/tcp
pr-142-mock-api-1 0.0.0.0:49181->8080/tcp
pr-143-app-1 0.0.0.0:49182->3000/tcp
pr-143-mock-api-1 0.0.0.0:49183->8080/tcp
Gotchas and edge cases
COMPOSE_PROJECT_NAMEmust be lowercase and start with a letter or digit. Compose rejects names with uppercase letters or a leading dash. A branch-derived name likeFeature/APIbreaks;pr-142is safe. Always namespace by the numeric PR id, never the branch name.- A blank host port confuses people reading logs.
docker compose port app 3000returns something like0.0.0.0:49180; you must split off the port withcut -d: -f2. Forgetting this passes the whole0.0.0.0:49180string into a URL and E2E fails to resolve it. - The comment step needs
pull-requests: write. Without the permission block,github-scriptthrowsResource not accessible by integration. Grant it at the job or workflow level, and remember that PRs from forks run with a read-only token — for fork previews, usepull_request_targetwith care or gate on a label.
What to seed a per-branch stack with
A stack that starts cleanly and serves an empty list is a stack nobody looks at twice. Seeding is what converts the infrastructure into something a reviewer can use, and it is worth treating as part of the stack definition rather than as an afterthought.
Three seeding strategies work, in increasing order of effort.
A committed fixture set is the default and is right for most changes. The same generated JSON that drives unit tests is loaded into the mock at stack start, so the preview shows the data every engineer already recognises. Because it is generated deterministically, two stacks built from the same commit show identical data, which means a reviewer’s screenshot is reproducible.
A per-branch overlay adds records specific to the change. A pull request that introduces a new order state can ship a small overlay file containing an order in that state, loaded on top of the base fixtures. The overlay lives in the branch, is reviewed with the code, and disappears when the branch merges — which is exactly the lifetime the extra data should have.
A scenario primed to a mid-flow position is the most work and occasionally the only thing that demonstrates a change. If the feature is the third step of a checkout, a reviewer should not have to complete the first two by hand. Priming the scenario at stack start puts them where the change is.
Whichever strategy applies, the seeding step belongs behind the same health gate as everything else. A stack that is reachable before its data is loaded shows an empty state to whoever opens the URL first, and the resulting “this is broken” comment costs more than the gate would have.
Finally, seed the failure cases too. A preview stack where every request succeeds demonstrates the happy path only, and the reviewer most likely to catch a problem is the one who tries the thing that goes wrong. Exposing the fault switch — a header, or a query parameter the preview build honours — turns a stack from a demonstration into something that can actually be probed.
Making the stack cheap enough to be used
A per-branch stack is used in proportion to how quickly it appears. Past roughly a minute from push to usable URL, reviewers stop waiting and go back to reading the diff, and everything spent on the stack after that point is waste.
Three costs dominate, and all three are addressable.
Image pulls. A cold runner pulling a mock image, a database image and a runtime image spends most of the provisioning time on the network. Pre-pulling the pinned images on the runner, or using a registry mirror close to it, typically removes more seconds than any other single change.
Sequential startup. Services started one after another, each with its own health gate, add their startup times together. Services with no dependency between them should start concurrently, with the gate waiting on all of them at once rather than on each in turn.
Seeding through the interface. Priming a stack by driving the application is slow and fragile. Loading fixtures directly into the mock through its admin API takes milliseconds and cannot fail halfway.
There is a fourth, less obvious cost: the URL arriving late. If the comment with the preview link is posted after the whole pipeline finishes, the reviewer waits for the test suite as well as for the stack. Posting the URL as soon as the stack is healthy — before the tests run — lets review and testing proceed in parallel, which often halves the perceived wait for no additional infrastructure.
The measure worth tracking is time from push to a usable URL, not total pipeline duration. They are different numbers, and only the first one determines whether anybody opens the link.
Related
- Resetting Mock State Between Test Runs — keeping a reused per-PR stack’s data clean between E2E passes
- Ephemeral Preview Environments — the parent guide covering teardown and orphan sweeps
- Dockerized Mock Environments — the Compose images each PR stack instantiates
← Back to Ephemeral Preview Environments