Deploying

A preview of every pull request, on your own server

What per-pull-request preview environments need on your own server, a tested do-it-yourself setup with Docker Compose, Caddy and GitHub Actions, and what OpsCrew's previews add.

TL;DR

  • A preview environment is a running copy of your app for one pull request, at its own address, created when the pull request opens and removed when it closes.
  • On your own server it needs: a wildcard DNS record, a route and a certificate per preview, its own throwaway database, protection from strangers, test-only secrets, and cleanup.
  • Run previews on a server of their own: anyone who can push a branch runs code there.
  • Our tested setup uses one Compose project per pull request, a Caddy (opens in a new tab) site file per preview with basic auth and noindex, a script that validates its input, and a GitHub Actions (opens in a new tab) workflow for open, push and close.
On this page

A preview environment for a pull request is a running copy of your app, built from that pull request's code, at its own address and with its own data, created when the pull request opens, updated on each push and deleted when it closes. On your own server that takes a wildcard DNS record, a route and a certificate per preview, a throwaway database per preview, a login in front, test-only secrets, and cleanup on close. Hosting platforms give you this out of the box; on a VPS you build it, or let a tool build it.

This article belongs to our guide to deploying a web app to a VPS.

What a pull request preview environment needs

A server that is not production

A preview runs the code of an unmerged pull request. Anyone who can push a branch to your repository can therefore run code on the preview server, read what is on it and reach what it can reach. Run previews on a server with nothing else on it: no production data, no production secrets, no private network to your other machines. A small server is enough; the cap on how many previews run at once decides its size.

An address per preview

Each preview needs its own hostname, such as pr-42.preview.example.com. One wildcard DNS record (*.preview.example.com) pointing at the preview server covers all of them, so creating a preview never touches DNS. Never serve previews on your app's own hostname. And if your app sets cookies with a Domain attribute (Domain=example.com), use a separate domain for previews: such a cookie is sent to every subdomain, pr-42.preview.example.com included, where a pull request's code could read it. Cookies set without Domain stay on the host that set them.

A certificate per preview, or one wildcard

Two ways to get HTTPS:

  • A certificate per name. Caddy requests one for each new preview hostname automatically. Simple, but two catches: Let's Encrypt submits every certificate it issues to Certificate Transparency logs, which anyone can search, so each preview's name becomes public; and Let's Encrypt allows 50 new certificates per registered domain every 7 days, shared with everything else under that domain.
  • One wildcard certificate for *.preview.example.com. Let's Encrypt issues wildcards only through the DNS challenge, which needs API credentials for your DNS provider and, in Caddy's case, a DNS provider module (provider support is community-maintained). Only the wildcard name appears in the logs.

Either way, a hard-to-guess address is not protection on its own: add a login.

Data for each preview

Give each preview its own database, started empty, migrated by the release's migrations and filled by a seed script. Throw it away with the preview. Sharing production's database is never acceptable; sharing one staging database between previews works, but one pull request's migration can then break every other preview.

Secrets: test keys only

Previews need their own values (a payment provider's test mode, a sandbox mail service, API keys you can revoke). Assume a pull request's author can read whatever you put on the preview server.

GitHub (opens in a new tab) helps here: for workflows triggered by pull_request from a fork, it passes no secrets (only a read-only GITHUB_TOKEN), so forks cannot reach your server. Keep it that way, and only build previews for branches of your own repository.

Branches of your own repository are another matter. A pull request's job runs the workflow file from the pull request, so its author can make the job print any secret the job can reach, repository secrets included. Keep production's deploy secrets in a GitHub environment whose deployment branch rule allows only main, as in the Hetzner tutorial, so no pull request job can read them. In a private repository that needs GitHub Pro, Team or Enterprise; on GitHub Free, environment secrets and branch rules work only in public repositories, so a private repository on Free cannot keep production's secrets away from a preview job.

Protection

  • A login in front of every preview (basic auth is enough), so a link that leaks does not open the app to everyone.
  • An X-Robots-Tag: noindex header, so search engines do not index a preview someone linked to.

Cleanup

GitHub sends a closed event for both merged and unmerged pull requests. On it, remove the preview's containers, its volumes (its data) and its route. Add a timer on the server that removes previews idle for a week, in case an event is missed, and delete old preview images from the registry.

A tested do-it-yourself setup

The setup below covers each point with Docker (opens in a new tab) Compose, Caddy and GitHub Actions. We ran it locally on 11 November 2026 with Docker 29.9.3, Compose 5.7, Caddy 2.11.9 and PostgreSQL (opens in a new tab) 18.6, using two previews of a Next.js (opens in a new tab) app built from two different commits. The shell script passes ShellCheck 0.11.0 and the workflow passes actionlint 1.7.12; we did not run the workflow on GitHub or create a server.

The files on the preview server:

/srv/previews/
├── edge/
│   ├── compose.yaml        # the shared Caddy
│   ├── Caddyfile
│   ├── .env                # REVIEWER_PASSWORD_HASH
│   └── previews/           # one site file per preview, written by preview.sh
├── compose.preview.yaml    # one preview: app + its own PostgreSQL
├── site.caddy.template
└── preview.sh

The shared proxy

edge/compose.yaml runs one Caddy for all previews and creates the network that previews join:

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    environment:
      REVIEWER_PASSWORD_HASH: ${REVIEWER_PASSWORD_HASH}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./previews:/etc/caddy/previews:ro
      - caddy-data:/data
      - caddy-config:/config
    networks: [previews]

networks:
  previews:
    name: previews

volumes:
  caddy-data:
  caddy-config:

edge/Caddyfile is one line. It imports whatever preview sites exist; with none, Caddy only logs a warning:

import /etc/caddy/previews/*.caddy

Create the reviewers' password hash with docker run --rm caddy:2 caddy hash-password --plaintext 'a-long-password', and put it in edge/.env in single quotes, because the hash contains $ signs that Compose would otherwise try to expand:

REVIEWER_PASSWORD_HASH='paste-the-hash-here'

Start it once with docker compose up -d in edge/.

One preview

compose.preview.yaml describes one preview. Each preview runs as its own Compose project (pr-42), so its containers, network and volume are separate from every other preview's:

services:
  app:
    image: ghcr.io/your-name/myapp:${APP_TAG:-none}
    restart: unless-stopped
    environment:
      DATABASE_URL: postgresql://app:preview@db:5432/app
    mem_limit: 512m
    depends_on:
      db:
        condition: service_healthy
    networks:
      default:
      previews:
        aliases: ["${PREVIEW}-app"]

  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD: preview
    mem_limit: 256m
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

networks:
  previews:
    external: true

volumes:
  db-data:

The app joins the shared previews network under an alias such as pr-42-app, which is how Caddy finds it; the database stays on the preview's own network, where only its app can reach it. The fixed database password is deliberate: the database holds throwaway data and is reachable from nothing but its own app. The memory limits keep one runaway preview from starving the others.

The route and the protection

site.caddy.template becomes one Caddy site per preview:

PREVIEW_NAME.preview.example.com {
	basic_auth {
		reviewer {$REVIEWER_PASSWORD_HASH}
	}
	header X-Robots-Tag "noindex, nofollow"
	reverse_proxy PREVIEW_NAME-app:3000
}

The script

preview.sh is the only thing the workflow runs on the server:

#!/bin/sh
# Usage: preview.sh up <pull request number> <commit sha>
#        preview.sh down <pull request number>
set -eu
cd "$(dirname "$0")"
case "${2:-}" in ''|*[!0-9]*) echo "not a pull request number: ${2:-}" >&2; exit 1 ;; esac
export PREVIEW="pr-$2"

case "$1" in
  up)
    case "${3:-}" in ''|*[!0-9a-f]*) echo "not a commit sha: ${3:-}" >&2; exit 1 ;; esac
    export APP_TAG="$3"
    docker compose -p "$PREVIEW" -f compose.preview.yaml up -d --wait --wait-timeout 120
    sed "s/PREVIEW_NAME/$PREVIEW/g" site.caddy.template > "edge/previews/$PREVIEW.caddy"
    ;;
  down)
    docker compose -p "$PREVIEW" -f compose.preview.yaml down --volumes
    rm -f "edge/previews/$PREVIEW.caddy"
    ;;
  *)
    echo "usage: preview.sh up <number> <sha> | preview.sh down <number>" >&2
    exit 1
    ;;
esac

docker compose -f edge/compose.yaml exec -T caddy caddy reload --config /etc/caddy/Caddyfile
  • It refuses bad input before doing anything: the number must be digits only and the SHA hexadecimal. In our test, preview.sh up '1;rm -rf /' abc and preview.sh up 3 'abc;ls' both stopped with an error and changed nothing.
  • up starts or updates the preview and waits until its containers are healthy (--wait), so a broken pull request fails the workflow instead of publishing a dead route; Compose pulls the new image itself, since each commit has its own tag.
  • down --volumes removes the containers, the network and the database volume.
  • caddy reload gives the running Caddy the new set of sites. Caddy's documentation calls it the correct way to change a running configuration, and warns that stopping the server to change it causes downtime.

What the test showed

With pr-1 on one commit and pr-2 on another:

  • each preview answered 401 without the password and 200 with it, with X-Robots-Tag: noindex, nofollow, and each served its own commit's page;
  • an unknown name such as pr-9.preview.example.com got no answer, because Caddy has no site or certificate for it;
  • after preview.sh down 1, pr-1 stopped answering, its containers and its database volume were gone, and pr-2 kept answering 200.

Locally Caddy could not get public certificates, so the test ran with Caddy's local_certs global option, which makes it sign certificates itself. On a real server you leave that out and Caddy requests a certificate for each preview name.

The workflow

.github/workflows/preview.yml builds and deploys on open, push and reopen, and removes on close:

name: Preview

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

env:
  IMAGE: ghcr.io/your-name/myapp

jobs:
  up:
    # Only same-repository pull requests: a fork's job gets no secrets anyway.
    if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write
    environment:
      name: preview
      url: https://pr-${{ github.event.pull_request.number }}.preview.example.com
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE }}:${{ github.sha }}
      - name: Start or update the preview
        env:
          SSH_PRIVATE_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.PREVIEW_KNOWN_HOSTS }}
          PREVIEW_HOST: ${{ secrets.PREVIEW_HOST }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/preview_key
          chmod 600 ~/.ssh/preview_key
          printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
          ssh -i ~/.ssh/preview_key "deploy@$PREVIEW_HOST" "/srv/previews/preview.sh up $PR_NUMBER $GITHUB_SHA"

  down:
    if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-24.04
    permissions: {}
    environment: preview
    steps:
      - name: Remove the preview and its data
        env:
          SSH_PRIVATE_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.PREVIEW_KNOWN_HOSTS }}
          PREVIEW_HOST: ${{ secrets.PREVIEW_HOST }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/preview_key
          chmod 600 ~/.ssh/preview_key
          printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
          ssh -i ~/.ssh/preview_key "deploy@$PREVIEW_HOST" "/srv/previews/preview.sh down $PR_NUMBER"

Notes:

  • The image tag is github.sha. For pull_request events, GitHub sets GITHUB_SHA to the merge commit it prepares for the pull request (refs/pull/<number>/merge), which is what actions/checkout checks out, so the tag matches what was built.
  • concurrency with cancel-in-progress: true cancels a preview build that a newer push or the close event has made pointless.
  • The preview environment's url gives the pull request a link to the preview. Store the secrets in that environment (or as repository secrets where the plan limit above applies), and use a key that works only on the preview server.
  • A stricter key. The workflow file belongs to the pull request, so a pull request can change what the job runs with that key. OpenSSH's authorized_keys options (command= and restrict) can limit a key to one command, so the key can run preview.sh and nothing else, whatever the workflow says.

What this setup leaves out

A cap on the number of previews, an idle timeout, seeding the database, deleting old images from the registry, and a comment on the pull request with the address. Each is a few more lines; together they are where a do-it-yourself setup starts to need maintenance.

How OpsCrew's previews work

OpsCrew, our open-source deploy tool, builds all of this for you as an environment named preview (previews, environments). From its documentation:

  • One small server of their own at Hetzner Cloud (opens in a new tab) or DigitalOcean (opens in a new tab), or a server you already have with nothing else on it, with its monthly price shown before anything is created. It refuses a server where another environment or app runs, unless you choose a dev or staging server after a warning, and it never puts previews beside prod.
  • Addresses nobody can guess, such as pr-42-k3x9q7m2ab.example.net, on a domain used only for previews, behind Cloudflare (opens in a new tab)'s proxy with one wildcard record and a Cloudflare Origin CA certificate, so no public certificate names a preview and there is no weekly certificate limit. Previews send X-Robots-Tag: noindex.
  • Each preview's own databases and services, started empty, migrated by the release command and filled once by a seed command; values from .env.preview, never from prod.
  • One approval for the recipe. You approve, once, the bounds every preview stays within (cap, lifetime, image, services, seed command). The server enforces them: the key GitHub gets can only start, update, remove or list previews, and a pull request that edits the workflow can still do only what the recipe allows.
  • A secrets check that refuses to set previews up while another environment's deploy secrets are readable by a pull request's job.
  • Cleanup: removal with its data on close or merge, after 7 idle days by default, and image cleanup in the registry. Pull requests from forks and from Dependabot get no preview, and a draft gets one when it is marked ready for review.
  • A comment on the pull request, kept up to date with the address and the state, and opscrew preview list, logs and destroy commands.

Where previews fit

Previews come after the basics: a production deploy that works, updates without downtime and backups. If you are not there yet, start with the complete guide to deploying an app to a VPS and the GitHub Actions deploy for Hetzner, whose build steps the preview workflow reuses.

Key facts

FactValueSource
Let's Encrypt: new certificates per registered domain50 every 7 daysShared by every certificate for names under the same registered domain, previews included.Let's Encrypt rate limits (opens in a new tab)Verified on
Wildcard certificates from Let's EncryptNeed the DNS-01 challenge; HTTP-01 cannot issue themLet's Encrypt challenge types (opens in a new tab)Verified on
pull_request events by defaultopened, synchronize and reopened; add closed to clean upGitHub Docs, events that trigger workflows (opens in a new tab)Verified on
Secrets in pull requests from forksNot passed to the runner, except a read-only GITHUB_TOKENGitHub Docs, events that trigger workflows (opens in a new tab)Verified on

Frequently asked questions

Can pull requests from forks get previews?

Not with the pull_request event: GitHub withholds secrets from those workflows and gives them a read-only token, so they cannot reach your server. That protects you; a stranger's fork should not run code on your infrastructure. Review such changes first and push them to a branch of your repository if you want a preview.

How many previews can one server run?

Divide the memory you can spare by what one preview needs. In our test a Next.js app idled at about 50 to 65 MB and PostgreSQL at about 40 MB, and we capped them at 512 MB and 256 MB. Set a cap on the number of previews and refuse new ones when it is reached, rather than letting the server run out of memory.

Sources

  1. Events that trigger workflows: pull_request (opens in a new tab), GitHub, accessed on
  2. Using secrets in GitHub Actions (opens in a new tab), GitHub, accessed on
  3. Deployments and environments (opens in a new tab), GitHub, accessed on
  4. Rate limits (opens in a new tab), Let's Encrypt, accessed on
  5. Challenge types (opens in a new tab), Let's Encrypt, accessed on
  6. Automatic HTTPS (wildcards, DNS challenge, on-demand TLS) (opens in a new tab), Caddy, accessed on
  7. reverse_proxy directive (opens in a new tab), Caddy, accessed on
  8. Certificate Transparency (CT) logs (opens in a new tab), Let's Encrypt, accessed on
  9. Set-Cookie header (Domain attribute) (opens in a new tab), MDN Web Docs, accessed on
  10. sshd(8), authorized_keys file format (opens in a new tab), OpenBSD, accessed on
  11. Caddy command line (reload, hash-password) (opens in a new tab), Caddy, accessed on
  12. OpsCrew docs, Previews (opens in a new tab), OpsCrew, accessed on
  • Deploying

    How to deploy a web app to a VPS: the complete guide

    From an empty server to a production app: server size, Docker, a reverse proxy with HTTPS, DNS, CI/CD, secrets, backups, zero-downtime updates, previews, logs and hardening.

    12 min read

  • Deploying

    Deploy Next.js to a VPS with Docker and HTTPS

    A tested setup for Next.js 16 on your own server: standalone output, a small multi-stage Docker image, PostgreSQL and Caddy with automatic HTTPS, and the build-time traps that break apps after the first deploy.

    8 min read

  • Deploying

    Deploy a Docker Compose app to Hetzner with GitHub Actions

    A Hetzner Cloud server set up by a tested cloud-init file, behind Hetzner's firewall, updated by a GitHub Actions workflow that builds your image, pushes it to GHCR and deploys it over SSH.

    10 min read

Put OpsCrew to work on your app.

Run npx opscrew init in your app's repository. It checks your tools and tokens first, for free.