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.

How two pull requests avoid each other Each pull request derives a project name from its number, which namespaces the Compose project, the network, the volumes and the container names. Ports are allocated dynamically and read back rather than fixed. Nothing in the stack carries a name that another pull request could also produce. One derived name, and every resource inherits it — which is what makes collision impossible rather than unlikely. PR number the one stable identifier from the event payload Project name namespaces everything network, volumes, containers Dynamic ports published as 0:8080 read back after start Injected URL passed to the test run no fixed port anywhere A single fixed port anywhere in the stack reintroduces the collision the namespacing was meant to prevent.

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.

Everything that needs the namespace Five resources. The Compose project, the network, named volumes, container names and published ports. Each is paired with the failure that appears when it alone is left un-namespaced, from a silent volume share to a bind failure that reads as flake. Resource Namespaced by Symptom when missed Compose project -p pr-1234 one PR tears down another's stack network the project name containers reach the wrong stack named volumes the project name two PRs share mutable state container names the project name a name conflict on start published ports dynamic allocation a bind failure that reads as flake Four of the five come free with the project name; the fifth has to be done deliberately.

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_NAME must be lowercase and start with a letter or digit. Compose rejects names with uppercase letters or a leading dash. A branch-derived name like Feature/API breaks; pr-142 is safe. Always namespace by the numeric PR id, never the branch name.
  • A blank host port confuses people reading logs. docker compose port app 3000 returns something like 0.0.0.0:49180; you must split off the port with cut -d: -f2. Forgetting this passes the whole 0.0.0.0:49180 string into a URL and E2E fails to resolve it.
  • The comment step needs pull-requests: write. Without the permission block, github-script throws Resource 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, use pull_request_target with care or gate on a label.

Teardown that survives a cancelled job Three sweep conditions. A stack older than a threshold is reclaimed regardless of state. A stack whose pull request is closed or merged is reclaimed. A stack with no matching pull request at all — created by a job that was cancelled mid-run — is reclaimed on the next sweep. Older than the threshold the job may have been cancelled reclaim by project label and age Pull request closed or merged the normal path, plus the ones that failed reclaim on the webhook and on the sweep No matching pull request created by a job that never finished reclaim — nothing will ever claim it Only the middle row runs on the happy path, which is why a runner without the other two fills up quietly.

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.

← Back to Ephemeral Preview Environments