Resource library

QA How-To

Create a Preview Environment per Pull Request

Learn to create preview environment per pull request with Docker, GitHub Actions, HTTPS routing, health checks, test gates, and automatic cleanup safely.

20 min read | 2,698 words

TL;DR

Build the pull request commit into an immutable container image, deploy it under a Compose project named from the PR number, route a unique HTTPS hostname to it, verify health, and destroy the project when the PR closes. Treat deployment credentials, forked contributions, database isolation, and cleanup as first-class design concerns.

Key Takeaways

  • Give every pull request a deterministic name based on its immutable pull request number.
  • Build an immutable container image once, then deploy that exact digest to the preview host.
  • Use Docker Compose project names to isolate containers, networks, volumes, and ports.
  • Wait for an application health endpoint before publishing the preview URL or running tests.
  • Protect forked pull requests because repository secrets are unavailable and untrusted code must not reach deployment credentials.
  • Make cleanup idempotent so retries and already-removed environments both succeed.
  • Add TTL cleanup as a second safety net for abandoned workflows and deleted branches.

To create preview environment per pull request, turn each pull request number into a stable deployment identity, build its commit as an immutable image, deploy that image into an isolated runtime, and delete the runtime when the pull request closes. The result gives reviewers a real URL for exploratory testing without sharing a mutable staging environment.

This tutorial implements that lifecycle with GitHub Actions, GitHub Container Registry (GHCR), Docker Compose, and a Linux preview host behind an HTTPS reverse proxy. For the architecture and platform choices behind this implementation, read the ephemeral test environments complete guide.

You will use a PR number, not a branch name, as the environment key. PR numbers are short, immutable within a repository, safe to normalize, and available on open, synchronize, reopen, and closed events.

What You Will Build

By the end, your repository will have a preview delivery path that:

  • builds the exact pull request commit into ghcr.io/OWNER/REPO:pr-123-SHA;
  • deploys an isolated Compose project named preview-123;
  • exposes https://pr-123.preview.example.com;
  • waits for /healthz, then runs a browser smoke test;
  • posts or updates one pull request comment with the URL and status;
  • removes containers, networks, volumes, routing metadata, and images on close.

The example assumes one application container listening on port 3000. Adapt the health path and port to your service, but preserve the identity, isolation, verification, and cleanup model.

Concern Tutorial choice Why
Environment identity PR number Stable across pushes and branch renames
Artifact OCI image tagged with commit SHA Deploys the reviewed code exactly
Isolation Compose project per PR Namespaces containers, network, and volumes
Routing Wildcard DNS plus reverse proxy Produces one HTTPS hostname per PR
Readiness Application health endpoint Avoids confusing process start with usable service
Cleanup PR close workflow plus TTL sweep Handles normal and missed teardown events

Prerequisites

Use these current, version-conscious prerequisites. Pin action major versions in source control and review Dependabot updates.

  • A GitHub repository with Actions enabled and package write permission.
  • Docker Engine 27 or newer with Docker Compose v2 on an Ubuntu 24.04 LTS preview host.
  • Node.js 22 LTS for the sample application and smoke test.
  • A wildcard DNS record such as *.preview.example.com pointing to the host.
  • A reverse proxy that discovers Docker labels, such as Traefik 3, and owns ports 80 and 443.
  • SSH access from GitHub Actions using a restricted deployment account.

Confirm the local tools before editing the workflow:

docker version
docker compose version
node --version
ssh -V

Expected output should show Docker Engine 27+, Compose v2, and Node 22.x. You also need repository secrets named PREVIEW_HOST, PREVIEW_USER, and PREVIEW_SSH_KEY. Store the base domain in a repository variable named PREVIEW_DOMAIN, for example preview.example.com.

Step 1: Make the Application Preview-Ready

A preview must expose an unambiguous readiness signal and accept configuration at runtime. Add a lightweight health endpoint that checks the application process and any dependency required to serve a basic request. Do not include slow third-party APIs in this endpoint.

For an Express application, create a complete server entry point:

import express from 'express';

const app = express();
const port = Number(process.env.PORT ?? 3000);
const environment = process.env.PREVIEW_ID ?? 'local';

app.get('/healthz', (_request, response) => {
  response.status(200).json({ status: 'ok', environment });
});

app.get('/', (_request, response) => {
  response.type('html').send(`<!doctype html>
    <html><body><h1>Preview ${environment}</h1></body></html>`);
});

app.listen(port, '0.0.0.0', () => {
  console.log(`Listening on port ${port}`);
});

Containerize it with a small, reproducible Dockerfile. Commit the lockfile so npm ci resolves the same dependency graph in CI:

FROM node:22-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production PORT=3000
COPY --from=dependencies /app/node_modules ./node_modules
COPY package.json server.js ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \
  CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "server.js"]

The non-root user limits damage if the process is compromised. The image contains no environment-specific secrets, so the same artifact can move through test stages.

Verify Step 1: run docker build -t preview-app:local ., then docker run --rm -d --name preview-local -p 3000:3000 -e PREVIEW_ID=local preview-app:local. Run curl --fail http://localhost:3000/healthz; expect {"status":"ok","environment":"local"}. Finish with docker stop preview-local.

Step 2: Prepare the Shared Preview Host

Create a restricted deployment user and directories on the Linux host. The user needs Docker access in this simple single-host design. Docker group membership is effectively root-level access, so dedicate this host to previews and do not mix it with production workloads.

sudo adduser --disabled-password --gecos '' previewdeploy
sudo usermod -aG docker previewdeploy
sudo install -d -o previewdeploy -g previewdeploy /srv/previews
sudo install -d -o previewdeploy -g previewdeploy /srv/preview-router

Run Traefik once as shared infrastructure. Save this as /srv/preview-router/compose.yaml:

services:
  traefik:
    image: traefik:v3.3
    restart: unless-stopped
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      - --certificatesresolvers.letsencrypt.acme.email=ops@example.com
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    networks:
      - preview-edge
volumes:
  letsencrypt:
networks:
  preview-edge:
    name: preview-edge

Replace the ACME email. Start the proxy with docker compose -f /srv/preview-router/compose.yaml up -d. The explicit preview-edge name gives every PR project one known network to join.

Security-sensitive teams should replace the raw Docker socket mount with a filtered socket proxy or use an orchestrator ingress controller. Keep this tutorial focused by using a dedicated, non-production host.

Verify Step 2: run docker network inspect preview-edge and docker compose -f /srv/preview-router/compose.yaml ps. The network must exist and the traefik service must be Up. Confirm ports 80 and 443 are reachable through the host firewall.

Step 3: Define the Per-PR Compose Project

Add deploy/preview.compose.yaml to the repository. It describes one PR environment but contains no hard-coded PR number:

services:
  app:
    image: ${IMAGE_REF:?IMAGE_REF is required}
    restart: unless-stopped
    environment:
      NODE_ENV: preview
      PREVIEW_ID: ${PREVIEW_ID:?PREVIEW_ID is required}
    networks:
      - default
      - preview-edge
    labels:
      traefik.enable: 'true'
      traefik.http.routers.preview-${PR_NUMBER}.rule: Host(`${PREVIEW_HOSTNAME}`)
      traefik.http.routers.preview-${PR_NUMBER}.entrypoints: websecure
      traefik.http.routers.preview-${PR_NUMBER}.tls.certresolver: letsencrypt
      traefik.http.services.preview-${PR_NUMBER}.loadbalancer.server.port: '3000'
    healthcheck:
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/healthz']
      interval: 10s
      timeout: 3s
      retries: 12
      start_period: 10s
networks:
  preview-edge:
    external: true

Compose interpolates the required image, preview ID, PR number, and hostname at deployment time. It creates a private default network for the project and attaches only the web application to the shared edge network. Do not publish a host port. Traefik reaches port 3000 over Docker networking, preventing port collisions between previews.

If your application needs a database, add it only to the private default network, give its volume a project-scoped name, and use a unique credential per preview. Follow the Testcontainers ephemeral database seeding tutorial when deterministic fixtures are more useful than a long-lived shared database.

Verify Step 3: on the preview host, export test values and render the model without starting it:

export IMAGE_REF=preview-app:local PREVIEW_ID=pr-999 PR_NUMBER=999
export PREVIEW_HOSTNAME=pr-999.preview.example.com
docker compose -p preview-999 -f deploy/preview.compose.yaml config

Expect a valid resolved configuration, router labels ending in 999, and no published ports entry. A missing required variable must make the command fail immediately.

Step 4: Build and Publish an Immutable Image

Create .github/workflows/preview-open.yaml. First build the pull request head commit and push it to GHCR. Using pull_request is safe for same-repository branches, but GitHub does not pass repository secrets to workflows triggered from forks. The job below explicitly skips forks rather than exposing deployment credentials to untrusted code.

name: Preview environment
on:
  pull_request:
    types: [opened, synchronize, reopened]
permissions:
  contents: read
  packages: write
  pull-requests: write
concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true
jobs:
  build:
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-24.04
    outputs:
      image: ${{ steps.meta.outputs.image }}
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        shell: bash
        run: |
          IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}:pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
          echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.image }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The checkout ref is the head SHA, not a floating branch. The tag combines PR identity with commit identity. Build cache accelerates repeat builds without changing the output tag. For stronger supply-chain controls, deploy the digest emitted by the build action instead of relying on a tag.

Verify Step 4: open a same-repository pull request. The build job should complete and the repository package page should show a tag like pr-42-a1b2.... Inspect the Actions log and confirm checkout reports the same SHA shown as the PR head commit.

Step 5: Create Preview Environment per Pull Request and Publish the URL

Append a deploy job to the same workflow. It copies only the Compose definition, creates a per-PR directory, pulls the immutable image, starts the project, and waits for the public endpoint.

  deploy:
    needs: build
    runs-on: ubuntu-24.04
    environment:
      name: preview-pr-${{ github.event.pull_request.number }}
      url: https://pr-${{ github.event.pull_request.number }}.${{ vars.PREVIEW_DOMAIN }}
    steps:
      - uses: actions/checkout@v4
      - name: Configure SSH
        shell: bash
        env:
          SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
          PREVIEW_HOST: ${{ secrets.PREVIEW_HOST }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/preview_key
          chmod 600 ~/.ssh/preview_key
          ssh-keyscan -H "$PREVIEW_HOST" >> ~/.ssh/known_hosts
      - name: Deploy Compose project
        shell: bash
        env:
          HOST: ${{ secrets.PREVIEW_HOST }}
          USER: ${{ secrets.PREVIEW_USER }}
          IMAGE_REF: ${{ needs.build.outputs.image }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          DOMAIN: ${{ vars.PREVIEW_DOMAIN }}
        run: |
          SSH="ssh -i ~/.ssh/preview_key $USER@$HOST"
          scp -i ~/.ssh/preview_key deploy/preview.compose.yaml \
            "$USER@$HOST:/tmp/preview-$PR_NUMBER.yaml"
          $SSH "mkdir -p /srv/previews/pr-$PR_NUMBER && mv /tmp/preview-$PR_NUMBER.yaml /srv/previews/pr-$PR_NUMBER/compose.yaml"
          $SSH "cd /srv/previews/pr-$PR_NUMBER && IMAGE_REF='$IMAGE_REF' PREVIEW_ID='pr-$PR_NUMBER' PR_NUMBER='$PR_NUMBER' PREVIEW_HOSTNAME='pr-$PR_NUMBER.$DOMAIN' docker compose -p preview-$PR_NUMBER pull && IMAGE_REF='$IMAGE_REF' PREVIEW_ID='pr-$PR_NUMBER' PR_NUMBER='$PR_NUMBER' PREVIEW_HOSTNAME='pr-$PR_NUMBER.$DOMAIN' docker compose -p preview-$PR_NUMBER up -d --remove-orphans"
      - name: Wait for public readiness
        shell: bash
        env:
          URL: https://pr-${{ github.event.pull_request.number }}.${{ vars.PREVIEW_DOMAIN }}/healthz
        run: |
          for attempt in {1..30}; do
            if curl --silent --show-error --fail --max-time 5 "$URL"; then
              exit 0
            fi
            sleep 10
          done
          echo "Preview did not become ready: $URL" >&2
          exit 1
      - name: Add preview summary
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          URL: https://pr-${{ github.event.pull_request.number }}.${{ vars.PREVIEW_DOMAIN }}
        run: |
          gh pr comment ${{ github.event.pull_request.number }} --body "Preview ready: $URL"

For production-grade host verification, precompute the SSH host key and store it as a secret or runner image asset. ssh-keyscan protects against later key changes but does not independently authenticate the first key observed. A GitHub environment can also require reviewer approval before deployment.

The readiness loop makes the URL useful before announcing success. A container can be running while migrations, caches, or listeners are not ready, so the public request is the final authority.

Verify Step 5: push a new commit. Open the URL from the workflow environment deployment and confirm the page displays the correct pr-N identity. Run curl --fail https://pr-N.preview.example.com/healthz and confirm the JSON environment matches the pull request number.

Step 6: Gate the Pull Request with a Browser Smoke Test

A healthy endpoint proves the process can answer, not that a user journey works. Add a small Playwright test after readiness. Install Playwright as a development dependency and commit its lockfile changes:

npm install --save-dev @playwright/test
npx playwright install chromium

Create tests/preview.spec.ts:

import { expect, test } from '@playwright/test';

test('preview renders its pull request identity', async ({ page }) => {
  const previewId = process.env.PREVIEW_ID;
  if (!previewId) throw new Error('PREVIEW_ID is required');

  await page.goto('/');
  await expect(page.getByRole('heading', { name: `Preview ${previewId}` })).toBeVisible();
});

Create playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  retries: 1,
  use: {
    baseURL: process.env.PREVIEW_URL,
    trace: 'retain-on-failure'
  }
});

Add this step after public readiness and before the comment:

      - name: Run preview smoke test
        env:
          PREVIEW_ID: pr-${{ github.event.pull_request.number }}
          PREVIEW_URL: https://pr-${{ github.event.pull_request.number }}.${{ vars.PREVIEW_DOMAIN }}
        run: |
          npm ci
          npx playwright install --with-deps chromium
          npx playwright test

Keep the gate intentionally small. Run broad regression suites elsewhere or shard them, since every push replaces this environment and cancels the older workflow through concurrency.

Verify Step 6: change the expected heading locally to force a failure and confirm the deployment check turns red. Restore it, push again, and expect one passing test. Confirm the trace is retained when a test fails, and upload it as an artifact if your team needs remote diagnosis.

Step 7: Destroy the Preview When the Pull Request Closes

Create .github/workflows/preview-close.yaml. Cleanup must run for both merged and unmerged pull requests because both produce the closed event. It must also be idempotent, meaning a retry succeeds when resources are already gone.

name: Remove preview environment
on:
  pull_request:
    types: [closed]
permissions:
  contents: read
concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true
jobs:
  destroy:
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-24.04
    steps:
      - name: Configure SSH
        shell: bash
        env:
          SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
          PREVIEW_HOST: ${{ secrets.PREVIEW_HOST }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/preview_key
          chmod 600 ~/.ssh/preview_key
          ssh-keyscan -H "$PREVIEW_HOST" >> ~/.ssh/known_hosts
      - name: Remove project
        shell: bash
        env:
          HOST: ${{ secrets.PREVIEW_HOST }}
          USER: ${{ secrets.PREVIEW_USER }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          ssh -i ~/.ssh/preview_key "$USER@$HOST" "
            if [ -f /srv/previews/pr-$PR_NUMBER/compose.yaml ]; then
              cd /srv/previews/pr-$PR_NUMBER
              docker compose -p preview-$PR_NUMBER down --volumes --remove-orphans || true
            fi
            rm -rf /srv/previews/pr-$PR_NUMBER
          "

down --volumes deletes project-managed data. Never point a preview service at a production database, bucket, or queue. If a preview legitimately uses retained data, list the resource explicitly and establish a separate retention policy.

Add a scheduled TTL sweep for defense in depth. A workflow can query open PR numbers with gh pr list, compare them with /srv/previews/pr-*, and remove unmatched projects older than a chosen grace period. The automatic stale preview environment cleanup guide provides a complete, auditable implementation.

Verify Step 7: close a disposable pull request. On the host, docker ps --filter label=com.docker.compose.project=preview-N must return no containers, docker volume ls must show no project volume, and /srv/previews/pr-N must not exist. Re-run the cleanup job and confirm it still succeeds.

Step 8: Harden Secrets, Data, and Concurrency

The workflow now works, but safe previews require boundaries. Use a dedicated cloud account, cluster, or host for previews. Give the deploy identity permission only to create and delete resources with a preview prefix. Rotate its SSH key and log every deployment and teardown.

Do not run privileged deployment steps for forked code. The tutorial skips forks because a malicious Dockerfile could attack the build runner or publish unwanted artifacts. To support external contributors, build in an unprivileged workflow, require maintainer approval, and deploy only a reviewed commit through a separately triggered trusted workflow. Never check out untrusted PR code inside a pull_request_target job that has secrets.

Inject preview-only credentials at runtime. Scope OAuth callbacks, cookies, webhooks, payment sandboxes, email sinks, object prefixes, and queues to the PR identity. Production copies must be minimized and de-identified before use. Apply the workflow in the production data masking for preview environments tutorial before any realistic dataset reaches the host.

Concurrency prevents two commits from racing on one PR environment. cancel-in-progress: true stops the older GitHub job, while Compose converges the host to the newest image. For additional protection, record the deployed SHA and reject an update if a newer SHA is already active.

Verify Step 8: open a PR from a fork and confirm the deployment job is skipped with no secret exposure. Push two same-repository commits quickly and confirm only the latest workflow completes. Inspect the running container with docker inspect and verify it has preview credentials only, no production secrets.

Troubleshooting

The hostname returns NXDOMAIN -> Check that the wildcard DNS record points to the preview host. Query dig pr-123.preview.example.com and compare the result with the host IP. DNS must work before Traefik can request a certificate.

Traefik returns 404 -> Inspect docker inspect preview-123-app-1 and confirm the router labels, hostname, and preview-edge network. Run docker logs for Traefik and verify the app container joined the shared network.

The workflow times out waiting for health -> Run docker compose -p preview-123 logs app on the host. Test /healthz inside the container, then test the public hostname. This separates application startup errors from proxy, certificate, and DNS failures.

GHCR returns permission denied -> Confirm workflow permissions include packages: write, package visibility permits the repository, and the login uses GITHUB_TOKEN. Ensure the host can authenticate for private image pulls, ideally with a read-only package token stored on the host.

Compose reports a variable is missing -> Pass all four required values in the same shell invocation as docker compose: IMAGE_REF, PREVIEW_ID, PR_NUMBER, and PREVIEW_HOSTNAME. Use docker compose config to inspect interpolation safely.

A closed PR still has resources -> Re-run the close workflow, then inspect its SSH and Compose output. Add the scheduled TTL reconciler because close events can be missed after workflow deletion, credential outages, or manual resource creation.

Where To Go Next

You now have the core delivery loop. Use the complete guide to ephemeral test environments to compare this single-host model with Kubernetes namespaces and managed preview platforms.

Next, make state as disposable as compute:

Also add observability labels for repository, PR, commit, owner, and expiry time. Route preview logs to a short-retention index, expose deployment state in the PR, and track provisioning failures without inventing an uptime promise for disposable systems.

Interview Questions and Answers

Q: Why use the pull request number instead of the branch name?

The PR number is immutable, compact, and straightforward to validate. Branch names can contain slashes, punctuation, and user-controlled text that is unsafe in DNS names or shell commands. A PR number also remains stable after new commits.

Q: How do you prevent two pushes from corrupting one preview?

Use a concurrency group keyed by the PR number and cancel the older run. Deploy immutable commit-tagged images through a declarative tool such as Compose or Kubernetes. Optionally record the active SHA and reject stale updates.

Q: Why is a health check not enough for a deployment gate?

A health check validates process readiness and critical dependencies, but it does not prove a user journey renders or behaves correctly. Add one small browser smoke test against the public URL. Keep deeper regression tests separate so preview feedback stays fast.

Q: What is dangerous about pull_request_target?

It runs in the base repository context and can access secrets. Checking out and executing untrusted fork code in that context can leak credentials or alter repository resources. Use a trusted approval boundary and never execute unreviewed PR code with privileged secrets.

Q: How should preview databases be isolated?

Give each preview its own database or schema, unique credentials, and an explicit deletion owner. Seed deterministic synthetic data when possible. If production-shaped data is required, minimize and mask it before the preview boundary.

Q: How do you guarantee cleanup?

You cannot rely on one event for a guarantee. Combine idempotent close-event teardown with a scheduled reconciler that compares live environments with open PRs and TTL metadata. Alert on failed deletion and track resources with ownership labels.

Best Practices to Create Preview Environment per Pull Request

  • Do build once and deploy the exact pull request commit. Do not rebuild mutable source on the preview host.
  • Do use normalized, allow-listed identifiers. Do not interpolate branch names directly into shell, DNS, SQL, or resource names.
  • Do separate preview credentials and data from production. Do not grant broad cloud or repository permissions.
  • Do wait for public readiness and run a user-facing smoke test. Do not announce a URL merely because a container started.
  • Do make teardown idempotent and add TTL reconciliation. Do not depend solely on the PR close webhook.
  • Do label every resource with PR, repository, SHA, owner, and expiry metadata. Do not create resources that cannot be attributed.
  • Do cap CPU, memory, storage, and environment count. Do not let a burst of pull requests exhaust shared infrastructure.

Conclusion

To create preview environment per pull request reliably, connect one immutable identity to the whole lifecycle: PR number, commit image, Compose project, hostname, checks, and deletion. The YAML is only part of the solution. Isolation, readiness, least privilege, fork safety, and reconciliation make it dependable.

Start with one disposable pull request and verify every checkpoint, including the second cleanup run. Then add database isolation, masked data, TTL cleanup, and observability before expanding the workflow across repositories.

Interview Questions and Answers

Design a preview environment lifecycle for pull requests.

Key every environment by PR number, build the head SHA into an immutable image, and deploy it into an isolated namespace with a unique hostname. Gate success on readiness and a browser smoke test. Destroy resources on PR close and run a TTL reconciler for missed events.

Why is the pull request number a better environment key than the branch name?

The PR number is stable, compact, and easy to validate as an integer. Branch names may change and can contain characters unsafe for DNS, resource names, and shell interpolation. The PR number also maps directly to review state.

How would you handle rapid consecutive pushes to one pull request?

Use a CI concurrency group keyed by PR number and cancel the older run. Deploy immutable images tagged by commit SHA through a convergent declarative command. A deployed-SHA check can provide extra protection against a stale job overwriting a newer deployment.

What security risk do forked pull requests introduce?

Fork code is untrusted and normal pull request workflows do not receive repository secrets. A privileged workflow that executes that code could leak deployment or repository credentials. Keep the build unprivileged and introduce an explicit trusted approval and deployment boundary.

What should a preview readiness gate test?

It should test an application health endpoint through the public route, not only container state. Add a small browser smoke test for a critical visible journey. This validates the application, proxy, DNS, TLS, and basic UI together.

How do you make preview cleanup reliable?

Make teardown idempotent, trigger it on every PR close, and remove project-scoped volumes and metadata. Add a scheduled reconciler that compares deployed resources with open PRs and expiry labels. Record failures and retain ownership labels so orphaned costs are attributable.

How should test data be managed in preview environments?

Prefer synthetic, deterministic seed data in an isolated database or schema. Use unique credentials and delete the data with the environment. If production-shaped records are essential, minimize and irreversibly mask sensitive fields before they enter the preview system.

Frequently Asked Questions

What is a preview environment per pull request?

It is a temporary deployment of the exact code proposed in one pull request, available at its own URL. It is isolated from other pull requests and removed after review or merge.

Should every pull request receive a preview environment?

Deploy previews for changes that can be executed safely and provide review value. Skip documentation-only changes and untrusted forks unless a maintainer approves a trusted deployment path.

How do preview environments work with forked pull requests?

GitHub does not pass normal repository secrets to fork-triggered pull request workflows. Build untrusted code without privileged credentials, require approval, and perform deployment in a separate trusted workflow that pins the reviewed commit.

How long should a preview environment live?

Keep it while the pull request is open and active, then remove it on close. Add an inactivity TTL and scheduled reconciliation to recover from missed close events and abandoned resources.

Can multiple preview environments share one database?

They can, but shared mutable state causes collisions and weakens cleanup. Prefer a database or schema per preview with unique credentials, deterministic seed data, and explicit deletion ownership.

Why use Docker Compose project names for previews?

A unique project name prefixes containers, networks, and managed volumes consistently. That creates a practical isolation and cleanup boundary on a single Docker host.

How do I prevent stale preview environments?

Run idempotent teardown on the pull request closed event and schedule a separate reconciler. The reconciler should compare deployed PR identities with currently open pull requests and remove expired or orphaned projects.

Related Guides