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