Give every pull request its own live website, so reviewers can click a link instead of cloning a branch.

Code review usually goes like this: a teammate opens a pull request, and you read the changes. But reading code is not the same as seeing the result. To try a change you have to switch branches, install packages, start the app and hope it works on your machine. Most people skip that step, and bugs slip through.

A preview environment fixes this. Every pull request automatically gets its own running copy of the app at its own web address, such as pr-42.preview.example.com. The link appears as a comment on the pull request, and disappears when the PR is closed. Designers, product managers and reviewers can all open it in a browser — no setup at all.

A preview environment lives exactly as long as its pull request.

In this tutorial we will build it with tools you may already use: Git (GitHub), containers (Docker) and DNS. No special platform is required — just one small server.

What you will build:

  • A container image of your app, built automatically for each pull request.
  • A small server that runs one container per pull request.
  • A wildcard DNS record, so every PR gets a working address with no manual setup.
  • A GitHub Actions workflow that creates, updates and removes previews and posts the link.

How it works

Imagine an apartment building with one street address and a doorman. Every visitor arrives at the same front door. The visitor says who they want to see (“Flat 42, please”), and the doorman points them to the right flat. Nobody needs their own street address.

Preview environments work the same way. There is one server (the building), a reverse proxy called Traefik (the doorman), and one container per pull request (a flat). The name in the web address decides which container answers.

From click to page: the name in the address picks the container.
  1. Ask. The browser asks DNS: “Where is pr-42.preview.example.com?”
  2. Answer. One wildcard DNS record replies with the server’s IP address, for any pr-* name.
  3. Connect. The browser goes to that server. Its request says which name it wanted (this is called the Host header).
  4. Route. Traefik reads the name and forwards the request to the container that owns it — here, pr-42.

The clever part is that nothing has to be configured by hand for a new pull request. DNS already matches every name. Traefik notices new containers on its own by reading their labels. All that is left is to start the container with the right label.

What you need

You needWhy
A GitHub repository with an app you can run in a containerThe pull requests we will attach previews to
A small Linux server with a public IP address (a basic cloud VM is enough)It runs the preview containers. Use a separate server, not your production one
A domain name you can edit DNS forTo create the wildcard record, for example *.preview.example.com
SSH access to the server and about 18 minutesYou will run a few setup commands once

Throughout the tutorial we use example.com and the sample address 203.0.113.10. Replace them with your own domain and server IP.

Put your app in a container

A container is a sealed box holding your app and everything it needs to run. The same box works on your laptop, in CI and on the preview server, and that is why it is perfect for previews. You describe the box in a file called Dockerfile. Here is one for a typical Node.js app that listens on port 3000:

Dockerfile
FROM node:20-alpine
WORKDIR /app

# install dependencies first so Docker can cache this layer
COPY package*.json ./
RUN npm ci --omit=dev

COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Reading it top to bottom: start from a small Node image, work inside /app, install dependencies, copy the rest of your code, and start the server. Copying package*.json first is a speed trick — Docker reuses the slow install step until your dependencies change.

Test it on your own computer before automating anything:

docker build -t my-app .
docker run --rm -p 3000:3000 my-app

Open http://localhost:3000. If your app appears, the hard part is done.

Prepare the server

Log in to your server over SSH and run the commands below. They install Docker, create a dedicated deploy user for the workflow, and create a private network. The network lets Traefik reach every preview container.

# 1. install Docker (Ubuntu / Debian)
curl -fsSL https://get.docker.com | sh

# 2. a dedicated user that GitHub Actions will log in as
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG docker deploy

# 3. a private network shared by Traefik and every preview container
docker network create preview-net

Next, create a key pair so GitHub Actions can log in as deploy. Do this on your own computer:

# run this on YOUR computer, not the server
ssh-keygen -t ed25519 -f preview_key -N "" -C "github-actions-preview"

# copy the PUBLIC key to the server's deploy user
ssh-copy-id -i preview_key.pub deploy@203.0.113.10

Keep the private key file (preview_key) safe — you will paste it into GitHub in Step 5. Also open ports 22, 80 and 443 in the server’s firewall.

Start Traefik, the doorman

On the server, create a folder /opt/traefik with this docker-compose.yml file, then start it:

/opt/traefik/docker-compose.yml
services:
  traefik:
    image: traefik:v3.6
    restart: unless-stopped
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=preview-net
      - --entrypoints.web.address=:80
    ports:
      - "80:80"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - preview-net

networks:
  preview-net:
    external: true
cd /opt/traefik
docker compose up -d

What do these lines mean? providers.docker tells Traefik to watch Docker for new containers. exposedbydefault=false means a container is only published if it opts in with a label — a safe default. Mounting docker.sock is how Traefik sees containers start and stop.

Check that it works. Traefik has no apps yet, so a “404 page not found” is the correct answer:

curl -i -H "Host: hello.preview.example.com" http://203.0.113.10/

Point wildcard DNS at the server

In your DNS provider’s dashboard, add one record:

TypeNameValue
A*.preview203.0.113.10 (your server’s IP)

The * is a wildcard: it matches any name in that position. So pr-1, pr-42 and banana will all lead to your server.

One wildcard record replaces an endless list of per-PR records.

Wait a minute or two, then check it from your computer:

dig +short pr-42.preview.example.com
# should print: 203.0.113.10

Write the two scripts

We keep the server-side logic in two short shell scripts inside your repository. The workflow will send them over SSH. This keeps the workflow readable and lets you test the scripts by hand.

scripts/preview-up.sh
#!/usr/bin/env bash
# scripts/preview-up.sh  -  usage: preview-up.sh <pr-number> <image> <domain>
set -euo pipefail

PR="$1"; IMAGE="$2"; DOMAIN="$3"
NAME="pr-${PR}"

docker pull "$IMAGE"
docker rm -f "$NAME" >/dev/null 2>&1 || true

docker run -d --name "$NAME" \
  --restart unless-stopped \
  --network preview-net \
  --memory 256m --cpus 0.5 \
  --label "traefik.enable=true" \
  --label "traefik.http.routers.${NAME}.rule=Host(\`${NAME}.${DOMAIN}\`)" \
  --label "traefik.http.services.${NAME}.loadbalancer.server.port=3000" \
  "$IMAGE"

echo "Preview ${NAME} is running"

Here is what happens, line by line:

  1. Names. The three inputs (PR number, image, domain) become a container name like pr-42.
  2. Pull and replace. It downloads the new image, then removes any old container with the same name, so pushing again simply replaces the preview.
  3. Limits. --memory and --cpus stop one heavy preview from starving the others.
  4. Labels. These are the doorman’s instructions: “publish this container, answer for the name pr-42.preview.example.com, and send traffic to port 3000.” Change 3000 here if your app uses another port.

And the tiny script that cleans up when the pull request is closed:

scripts/preview-down.sh
#!/usr/bin/env bash
# scripts/preview-down.sh  -  usage: preview-down.sh <pr-number>
set -euo pipefail

docker rm -f "pr-$1" >/dev/null 2>&1 || true
docker image prune -f >/dev/null
echo "Preview pr-$1 removed"

Commit both files to your repository. Traefik picks up the container a second after it starts — and forgets it as soon as it is removed.

Add the GitHub Actions workflow

Now we connect everything. The workflow listens for pull request events and decides which job to run:

Two jobs cover the whole life of a pull request.

Add four secrets first

In your repository go to Settings → Secrets and variables → Actions and create these secrets:

SecretWhat to put in it
PREVIEW_HOSTThe server’s IP address, e.g. 203.0.113.10
PREVIEW_USERdeploy
PREVIEW_SSH_KEYThe full contents of the private key file preview_key

(GITHUB_TOKEN is provided automatically, so there is nothing to add for it.) Then create the workflow file:

.github/workflows/preview.yml
name: Preview environments

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

# one run per PR at a time; a newer push cancels the older run
concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read
  packages: write
  pull-requests: write

env:
  PREVIEW_DOMAIN: preview.example.com   # <- change to your domain

jobs:
  deploy:
    # skip closed PRs and PRs from forks (forks get no secrets)
    if: >
      github.event.action != 'closed' &&
      github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Work out the image name
        run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}:pr-${{ github.event.pull_request.number }}" >> "$GITHUB_ENV"

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE }}

      - name: Deploy to the preview server
        env:
          SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
          SSH_HOST: ${{ secrets.PREVIEW_HOST }}
          SSH_USER: ${{ secrets.PREVIEW_USER }}
          REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ACTOR: ${{ github.actor }}
          PR: ${{ github.event.pull_request.number }}
        run: |
          install -m 700 -d ~/.ssh
          echo "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts

          # let the server download the image we just pushed
          echo "$REGISTRY_TOKEN" | ssh "$SSH_USER@$SSH_HOST" \
            "docker login ghcr.io -u '$ACTOR' --password-stdin"

          # run our script on the server
          ssh "$SSH_USER@$SSH_HOST" bash -s -- "$PR" "$IMAGE" "$PREVIEW_DOMAIN" \
            < scripts/preview-up.sh

      - name: Comment the preview link on the PR
        uses: actions/github-script@v7
        env:
          PREVIEW_URL: http://pr-${{ github.event.pull_request.number }}.${{ env.PREVIEW_DOMAIN }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        with:
          script: |
            const marker = '<!-- preview-link -->';
            const body = `${marker}\n🚀 **Preview ready:** ${process.env.PREVIEW_URL}\n\nBuilt from \`${process.env.HEAD_SHA.slice(0, 7)}\``;
            const { data: comments } = await github.rest.issues.listComments({
              ...context.repo, issue_number: context.issue.number,
            });
            const existing = comments.find(c => c.body.includes(marker));
            if (existing) {
              await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
            } else {
              await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body });
            }

  cleanup:
    if: >
      github.event.action == 'closed' &&
      github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Remove the preview
        env:
          SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
          SSH_HOST: ${{ secrets.PREVIEW_HOST }}
          SSH_USER: ${{ secrets.PREVIEW_USER }}
          PR: ${{ github.event.pull_request.number }}
        run: |
          install -m 700 -d ~/.ssh
          echo "$SSH_KEY" > ~/.ssh/id_ed25519 && chmod 600 ~/.ssh/id_ed25519
          ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts
          ssh "$SSH_USER@$SSH_HOST" bash -s -- "$PR" < scripts/preview-down.sh

The deploy job follows the same five steps every time:

What one push to a pull request sets in motion.

A few details worth understanding:

  • concurrency — if you push twice quickly, the older run is cancelled, so two deployments never fight over the same container.
  • The if: conditions — pull requests from forks do not get access to secrets, so we skip them. That is a safety feature, not a bug.
  • ${GITHUB_REPOSITORY,,} — the ,, makes the name lowercase, because container registries reject capital letters.
  • Secrets go in env:, not inside the command — this keeps them out of the script text and is the recommended way to handle untrusted input in workflows.
  • One comment, updated — the hidden <!-- preview-link --> marker lets the workflow find its own comment and edit it, instead of adding a new one on every push.

Try it out

Commit the Dockerfile, the two scripts and the workflow on a new branch, then open a pull request.

  1. Open the Actions tab on GitHub. A run named “Preview environments” should start.
  2. Wait for the deploy job to turn green. The first run takes longest, because nothing is cached yet.
  3. A comment appears on the pull request with a link like http://pr-42.preview.example.com. Click it — that is your app.
  4. Push another commit. The same link now shows the new code, and the same comment is updated.
  5. Merge or close the PR. The cleanup job runs and the link stops working.

You can peek at the server too. Each open pull request appears as one container:

docker ps --filter "name=pr-"

Add HTTPS (optional, recommended)

Everything so far uses plain http://. Browsers warn about that, and some features (like service workers or camera access) only work over HTTPS. Traefik can get a wildcard certificate from Let’s Encrypt, once, and use it for every preview. Wildcard certificates require proving you control the domain through DNS, so you need a DNS provider Traefik supports. The example uses Cloudflare.

Replace the compose file, and add a traefik.yml next to it:

/opt/traefik/traefik.yml
# /opt/traefik/traefik.yml
providers:
  docker:
    exposedByDefault: false
    network: preview-net

entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
  websecure:
    address: ":443"
    http:
      tls:
        certResolver: letsencrypt
        domains:
          - main: "preview.example.com"
            sans: ["*.preview.example.com"]

certificatesResolvers:
  letsencrypt:
    acme:
      email: you@example.com
      storage: /letsencrypt/acme.json
      dnsChallenge:
        provider: cloudflare
/opt/traefik/docker-compose.yml
services:
  traefik:
    image: traefik:v3.6
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    environment:
      - CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
      - ./letsencrypt:/letsencrypt
    networks:
      - preview-net

networks:
  preview-net:
    external: true

Put your DNS API token in a file called .env in the same folder (CF_DNS_API_TOKEN=…) and run chmod 600 .env. Create the folder for certificates with mkdir letsencrypt, then restart with docker compose up -d.

Finally, two small edits so previews use the secure entry point:

  1. In scripts/preview-up.sh, add one more label: --label "traefik.http.routers.${NAME}.entrypoints=websecure"
  2. In the workflow, change PREVIEW_URL to start with https://.

Keep it safe and cheap

A preview server runs code that has not been reviewed yet. That deserves some care:

  • Never connect previews to production data. Use fake or seeded data, or a throwaway database container. Any reviewer with the link can use the app.
  • Never put production secrets in a preview. Give previews their own low-power keys.
  • Use a separate server. Access to the Docker socket is effectively root access to that machine, and the deploy user’s docker group membership has the same power. Keep that machine small and disposable.
  • Pin the server’s SSH identity. The workflow above trusts the server on first contact with ssh-keyscan. For stricter setups, store the server’s host key in a secret and write that into known_hosts instead.
  • Keep the link private if the app is sensitive. A public preview URL is guessable (pr-1, pr-2…). Add basic authentication in Traefik or restrict access by IP address if needed.
  • Control the cost. The memory and CPU limits in preview-up.sh cap each preview. A cleanup on close removes the container, and if a PR is abandoned you can check for old ones with docker ps and remove them by hand.

When something goes wrong

What you seeMost likely causeFix
Link shows “404 page not found”Traefik cannot see the container, or the name does not matchRun docker logs traefik. Confirm the label host is exactly pr-NN.your-domain
Traefik logs “client version 1.24 is too old”Traefik is older than v3.6.1 and Docker Engine is 29 or newerUse traefik:v3.6 or newer
The link never loads at allDNS is not pointing at the server yet, or a firewall blocks the portRun dig +short on the name, and open ports 80 and 443
502 Bad GatewayThe app listens on a different port than the label saysSet loadbalancer.server.port to your app’s real port
Permission denied (publickey) in the workflowThe public key is not in the server’s authorized_keys, or the secret is incompleteRepeat ssh-copy-id and paste the whole private key, including the first and last lines
Build fails on “repository name must be lowercase”The image name has capital lettersKeep the ${GITHUB_REPOSITORY,,} lowercase trick from Step 5
Pull fails with “unauthorized”The server could not log in to the registryCheck the docker login line ran, and that the workflow has packages: write
Works, then randomly returns 404 for a container that has a health checkTraefik skips containers whose Docker health check is failingMake the health check use a page your app really serves

Where to go next

You now have a real preview pipeline. Some ideas to improve it:

  • Seed each preview with sample data so reviewers land on a page that already looks alive.
  • Run your tests in the same workflow and only deploy when they pass.
  • Add a scheduled clean-up that removes previews older than a week, as a safety net.
  • Point previews at a mock API so the frontend works before the backend exists — see Build a Mock API from an OpenAPI Spec in 10 Minutes.

If you would rather not run the server, the certificates and the clean-up yourself, JarDeploy is our product for exactly this: push to Git and get a live URL, with preview environments on every pull request.