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

← Back to Ephemeral Preview Environments