Give Every Pull Request Its Own Preview URL
05 Feb 2026Reviewing a pull request by reading a diff misses whatever the diff can’t show you: layout shifts, actual API responses, how the thing feels to click through. A preview URL per PR fixes that, and you don’t need Vercel or Netlify to get it — a VPS, Docker, and a reverse proxy do the job.
The pieces
You need four things:
- A way to build and run each PR’s branch in an isolated container.
- A wildcard DNS entry and TLS cert so
pr-123.preview.yourapp.comresolves. - A reverse proxy that routes subdomain → container.
- Automatic teardown when the PR closes, or you’ll leak containers until the box falls over.
End to end, a PR’s life looks like this:
flowchart LR
A["PR opened/updated"] --> B["GitHub Actions builds image"]
B --> C["Image shipped to VPS"]
C --> D["Traefik routes by subdomain"]
D --> E["Reviewer opens preview URL"]
F["PR closed"] --> G["Container removed"]
1. Wildcard DNS and TLS
Point a wildcard A record at your VPS:
*.preview.yourapp.com. A 203.0.113.10
Get a wildcard cert with certbot using DNS-01 challenge (HTTP-01 won’t work for wildcards):
certbot certonly --manual --preferred-challenges dns \
-d "*.preview.yourapp.com"
Most DNS providers have a certbot plugin that automates the DNS-01 TXT record instead of doing it manually each renewal — use it if your provider supports it (certbot-dns-cloudflare, etc.).
2. Reverse proxy that routes by subdomain
nginx-proxy or Traefik both do subdomain-based routing driven by container labels, so you don’t hand-edit nginx config per PR. Traefik example:
# docker-compose.yml for the PR's app container
services:
app:
image: myapp:pr-123
labels:
- "traefik.http.routers.pr-123.rule=Host(`pr-123.preview.yourapp.com`)"
- "traefik.http.routers.pr-123.tls=true"
- "traefik.http.routers.pr-123.tls.certresolver=letsencrypt"
Traefik watches the Docker socket, sees the label, and starts routing that subdomain to that container automatically — no proxy restart needed.
3. GitHub Actions workflow: build and deploy on PR
name: Preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t myapp:pr-${{ github.event.number }} .
- run: |
ssh deploy@your-vps "docker rm -f pr-${{ github.event.number }} || true"
docker save myapp:pr-${{ github.event.number }} | ssh deploy@your-vps docker load
ssh deploy@your-vps "docker run -d --name pr-${{ github.event.number }} \
--label traefik.http.routers.pr-${{ github.event.number }}.rule=Host\(\`pr-${{ github.event.number }}.preview.yourapp.com\`\) \
--network traefik-net myapp:pr-${{ github.event.number }}"
teardown:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: ssh deploy@your-vps "docker rm -f pr-${{ github.event.number }} || true"
Post the preview URL as a PR comment so reviewers don’t have to guess the subdomain — pr-${{ github.event.number }}.preview.yourapp.com is predictable, but a comment saves everyone a lookup.
Database: don’t share one across PR environments
The most common way this setup breaks: every preview container points at the same shared database, and PR #123’s migration test corrupts data that PR #124’s preview is also reading. Either spin up a scoped Postgres schema per PR, or seed a small SQLite/Postgres instance per preview container from a fixture — not production data, and not a database shared across previews.
Teardown discipline
Set a scheduled job that force-removes any preview container older than a few days, independent of the PR-closed hook — PRs get abandoned without being closed, and orphaned containers on the same box eventually eat all your disk and RAM.
# cron, daily
docker ps --filter "label=preview=true" --format '{{.Names}} {{.CreatedAt}}' | \
awk '$2 < "'$(date -d '3 days ago' +%F)'"' | \
awk '{print $1}' | xargs -r docker rm -f
Takeaway
This is maybe an hour of setup once you have Traefik and wildcard DNS working, and it turns “let me pull this branch and run it locally to check” into “click the link in the PR.” The teardown job is the part people skip and the part that bites them a month later.