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.
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.
- Ask. The browser asks DNS: “Where is
pr-42.preview.example.com?” - Answer. One wildcard DNS record replies with the server’s IP address, for any
pr-*name. - Connect. The browser goes to that server. Its request says which name it wanted (this is called the Host header).
- 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 need | Why |
|---|---|
| A GitHub repository with an app you can run in a container | The 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 for | To create the wildcard record, for example *.preview.example.com |
| SSH access to the server and about 18 minutes | You 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:
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-appOpen 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-netNext, 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.10Keep 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:
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: truecd /opt/traefik
docker compose up -dWhat 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:
| Type | Name | Value |
|---|---|---|
A | *.preview | 203.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.
Wait a minute or two, then check it from your computer:
dig +short pr-42.preview.example.com
# should print: 203.0.113.10Write 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.
#!/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:
- Names. The three inputs (PR number, image, domain) become a container name like
pr-42. - Pull and replace. It downloads the new image, then removes any old container with the same name, so pushing again simply replaces the preview.
- Limits.
--memoryand--cpusstop one heavy preview from starving the others. - 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:
#!/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:
Add four secrets first
In your repository go to Settings → Secrets and variables → Actions and create these secrets:
| Secret | What to put in it |
|---|---|
PREVIEW_HOST | The server’s IP address, e.g. 203.0.113.10 |
PREVIEW_USER | deploy |
PREVIEW_SSH_KEY | The 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:
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.shThe deploy job follows the same five steps every time:
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.
- Open the Actions tab on GitHub. A run named “Preview environments” should start.
- Wait for the deploy job to turn green. The first run takes longest, because nothing is cached yet.
- A comment appears on the pull request with a link like
http://pr-42.preview.example.com. Click it — that is your app. - Push another commit. The same link now shows the new code, and the same comment is updated.
- 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
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: cloudflareservices:
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: truePut 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:
- In
scripts/preview-up.sh, add one more label:--label "traefik.http.routers.${NAME}.entrypoints=websecure" - In the workflow, change
PREVIEW_URLto start withhttps://.
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
deployuser’sdockergroup 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 intoknown_hostsinstead. - 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.shcap each preview. A cleanup on close removes the container, and if a PR is abandoned you can check for old ones withdocker psand remove them by hand.
When something goes wrong
| What you see | Most likely cause | Fix |
|---|---|---|
| Link shows “404 page not found” | Traefik cannot see the container, or the name does not match | Run 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 newer | Use traefik:v3.6 or newer |
| The link never loads at all | DNS is not pointing at the server yet, or a firewall blocks the port | Run dig +short on the name, and open ports 80 and 443 |
502 Bad Gateway | The app listens on a different port than the label says | Set loadbalancer.server.port to your app’s real port |
Permission denied (publickey) in the workflow | The public key is not in the server’s authorized_keys, or the secret is incomplete | Repeat 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 letters | Keep the ${GITHUB_REPOSITORY,,} lowercase trick from Step 5 |
| Pull fails with “unauthorized” | The server could not log in to the registry | Check the docker login line ran, and that the workflow has packages: write |
| Works, then randomly returns 404 for a container that has a health check | Traefik skips containers whose Docker health check is failing | Make 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.