# Preview Environments for Every PR, Without YAML

> Set up preview environments for every pull request: a DIY webhook, Docker and Traefik pipeline, or one toggle in Temps. Compared with Coolify and Vercel.

David Viejo · Published 2026-03-12 · Updated 2026-10-20 · 12 min read

Canonical: https://temps.sh/blog/how-to-set-up-preview-environments-every-pull-request

## What Is the Easiest Way to Set Up Preview Environments for Every Pull Request?

The easiest way to set up preview environments for every pull request is to use a platform that handles it natively. With Temps, you connect your GitHub repository, turn on preview environments in the project settings, and every push to a non-main branch gets a live preview environment at its own URL — no GitHub Actions workflow to write and no reverse-proxy configuration. Open a PR and Temps posts a sticky comment that updates in place with the preview URL as each deployment finishes.

If you want full control and prefer to build it yourself, you can do it with webhooks, Docker and Traefik — but expect a few hours of initial setup plus ongoing maintenance. This guide walks through both.

---

## Temps vs Vercel vs Netlify: Preview Environment Comparison

Vendor prices as of October 2026.

| Feature                              | Temps (self-hosted)                                                                | Vercel                                                                       | Netlify                                                               |
| ------------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Auto-deploy every PR / branch        | Yes (enable preview environments per project)                                      | Yes                                                                          | Yes                                                                   |
| Idle cost of a preview               | Containers run 24/7 by default; opt-in on-demand mode scales them to zero          | Serverless — no idle containers                                              | Static + serverless — no idle containers                              |
| Cleanup                              | Soft-deleted when the branch is deleted; delete any time from the dashboard or CLI | Retention policy deletes old previews (180 days by default on Pro)           | Static deploys and functions — no containers to clean up              |
| Sticky PR comment (updates in place) | Yes — preview URL, or a logs link if the build fails                               | Yes                                                                          | Yes                                                                   |
| Custom domain for previews           | Yes — one wildcard DNS record on your domain                                       | Yes                                                                          | Yes                                                                   |
| Cost                                 | Free self-hosted; Temps Cloud from $29/mo (coming soon)                            | Pro $20/mo incl. 1 deploying seat, +$20 per extra deploying seat, plus usage | Pro $20/mo per team, unlimited members; Deploy Previews use 0 credits |
| Runs on your own server              | Yes                                                                                | No                                                                           | No                                                                    |
| Per-seat pricing                     | None                                                                               | Yes (deploying seats)                                                        | No (flat per team, credit-based)                                      |
| License                              | MIT / Apache 2.0                                                                   | Proprietary                                                                  | Proprietary                                                           |

How Temps does it: preview environments are a single project toggle that auto-creates a per-branch environment, deploys it and posts a sticky PR comment. An optional on-demand mode stops idle preview containers after a configurable timeout and wakes them on the next HTTP request. Temps is a single binary that also includes the Pingora-based reverse proxy, analytics, session replay, error tracking and uptime monitoring, so a reviewer's session and any errors from the preview land in the same place. It is free to self-host under MIT / Apache 2.0 — the only cost is the server. The setup steps are in the [preview deployment docs](https://temps.sh/docs/set-up-preview-deployments).

---

## Which Platform Should You Use Instead of Building It?

If you're comparing platforms rather than building the DIY pipeline below, see [the best platforms for preview deployments in 2026](https://temps.sh/blog/best-platforms-for-preview-deployments-2026), which compares Temps, Vercel, Netlify, Railway, Render and Coolify on access control, idle cost and cleanup. The rest of this guide is about building per-PR previews yourself, and what a platform takes off your plate.

---

Vercel's best-known feature isn't hosting. It's preview deployments. Every pull request gets a unique URL where reviewers can see exactly what changed, click around, and catch issues that never show up in a code diff. But Vercel charges per deploying seat, and preview usage counts against your plan. For a growing team, that's a recurring bill for what is essentially a container behind a subdomain.

The good news: you can build this for any Docker app on your own infrastructure. The pattern is straightforward — git webhook, container build, dynamic subdomain routing, PR comment with the URL, cleanup on merge. This guide walks through the full architecture, a working DIY implementation, and how self-hosted platforms handle it natively.

> **TL;DR:** Preview environments give every pull request a unique, shareable URL for visual review before merge. You can build them yourself with GitHub webhooks, Docker and wildcard DNS — or use a self-hosted platform like Temps that handles per-branch deploys, optional scale-to-zero and sticky PR comments out of the box.

---

## Why Do Preview Environments Change Everything?

Visual review catches what code review misses. A CSS change that looks fine in a diff can break an entire layout -- and nobody catches it until production.

Here's what preview environments unlock for each role on your team:

**For developers.** No more "pull my branch and run it locally" messages in Slack. Reviewers click a link, see the change, leave feedback. The review cycle drops from hours to minutes because there's zero setup friction.

**For designers.** They don't need Git access or a local dev environment. A URL in a PR comment is all they need to verify that the implementation matches the mockup. Pixel-level feedback happens in the PR, not in a separate design review meeting.

**For QA.** Testing against a deployed environment catches issues that localhost hides -- real DNS resolution, production-like networking, actual SSL certificates. QA can test multiple PRs simultaneously without juggling local branches.

**For PMs and stakeholders.** Feature demos happen by sharing a link, not scheduling a screen share. A PM can review a feature on their phone during lunch, leave a comment, and the developer sees it immediately.

The biggest factor isn't the technology -- it's removing the friction of "I'll review it when I get a chance to pull the branch." When reviewing is as easy as clicking a link, it happens sooner.

---

## What Does the Architecture of a Preview Environment Look Like?

The core architecture is a five-step pipeline driven by your Git provider's webhooks.

Here's the full flow:

```
┌──────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Git Push /  │────>│  Webhook Handler │────>│  Build Service  │
│  PR Opened   │     │  (validate, auth)│     │  (docker build) │
└──────────────┘     └──────────────────┘     └────────┬────────┘
                                                       │
                                                       v
┌──────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  PR Comment  │<────│  DNS / Routing   │<────│  Deploy to      │
│  with URL    │     │  (wildcard cert) │     │  Unique Subdomain│
└──────────────┘     └──────────────────┘     └─────────────────┘

On PR close/merge:
┌──────────────┐     ┌──────────────────┐
│  Webhook:    │────>│  Stop & Remove   │
│  PR closed   │     │  Container       │
└──────────────┘     └──────────────────┘
```

### The Five Pipeline Stages

**Stage 1: Webhook reception.** Your server listens for GitHub (or GitLab) webhook events -- specifically `pull_request.opened`, `pull_request.synchronize` (new push), and `pull_request.closed`. Each event carries the branch name, commit SHA, and PR number.

**Stage 2: Build.** Clone the repo at the specific commit, run `docker build`, and tag the image with the PR number or branch name. This is where most of the time goes -- a typical Node.js app takes 1-3 minutes to build.

**Stage 3: Deploy.** Start the container with environment variables pointing to preview-specific resources (database, API keys). Map it to a unique subdomain like `pr-42.preview.yourapp.com`.

**Stage 4: Notify.** Post a comment on the PR with the preview URL. Update the comment on each subsequent push rather than creating a new one. Set a GitHub commit status to "deployed" so reviewers know the preview is ready.

**Stage 5: Cleanup.** When the PR closes (merge or abandon), stop the container, remove the image, and clean up any preview-specific resources like databases or storage volumes.

---

## How Does Branch-to-Subdomain Mapping Work?

Wildcard DNS is the foundation of branch-to-subdomain routing, and most DNS providers (Cloudflare included) support wildcard records at no extra cost. A single `*.preview.yourapp.com` record pointing to your server means any subdomain resolves automatically -- no per-PR DNS configuration needed.

### The Subdomain Naming Pattern

You have two practical options for subdomain naming:

**PR-number based:** `pr-42.preview.yourapp.com`

- Predictable and short
- Easy to parse in automation scripts
- Downside: tells you nothing about the branch content

**Branch-name based:** `feature-auth-redesign.preview.yourapp.com`

- Human-readable -- you know what the PR contains from the URL
- Requires sanitization (replace `/` with `-`, strip special characters, truncate to 63 chars)
- Downside: long branch names create unwieldy URLs

Most teams use the PR-number pattern because it's simpler to implement and less error-prone. The PR comment contains the full context anyway.

### DNS Configuration

Set up a single wildcard A record:

```
*.preview.yourapp.com  →  A  →  YOUR_SERVER_IP
```

Or if you're behind a load balancer:

```
*.preview.yourapp.com  →  CNAME  →  lb.yourapp.com
```

That's it. Every subdomain under `preview.yourapp.com` now resolves to your server. The reverse proxy handles routing each subdomain to the correct container.

### Wildcard SSL with Let's Encrypt

HTTPS is non-negotiable -- browsers flag HTTP sites and many APIs refuse non-TLS connections. Let's Encrypt issues free wildcard certificates, but they require DNS-01 validation instead of the simpler HTTP-01 challenge. For the full picture of HTTP-01 vs DNS-01 and automatic renewal, see [how to set up custom domains with automatic SSL](https://temps.sh/blog/how-to-set-up-custom-domains-automatic-ssl).

Here's how to get a wildcard cert using `certbot` with the Cloudflare DNS plugin:

```bash
# Install certbot with Cloudflare plugin
pip install certbot certbot-dns-cloudflare

# Create Cloudflare credentials file
cat > /etc/letsencrypt/cloudflare.ini << EOF
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF
chmod 600 /etc/letsencrypt/cloudflare.ini

# Request wildcard certificate
certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d "*.preview.yourapp.com" \
  --preferred-challenges dns-01
```

Certbot installs a renewal timer, so the certificate renews before its 90-day expiry. One certificate covers every preview subdomain you'll ever create.

---

## How Do You Build Preview Environments from Scratch?

Building a working preview system takes a webhook handler of a couple of hundred lines plus reverse proxy configuration. Build time for each preview is the same as your normal CI build.

### Step 1: GitHub Webhook Handler

This Node.js server receives GitHub webhooks and manages the preview lifecycle:

```javascript
// preview-server.js
const express = require('express');
const crypto = require('crypto');
const { execSync } = require('child_process');
const { Octokit } = require('@octokit/rest');

const app = express();
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const PREVIEW_DOMAIN = 'preview.yourapp.com';
const REPO_DIR = '/tmp/preview-builds';

// Verify GitHub webhook signature
function verifySignature(req) {
  const sig = req.headers['x-hub-signature-256'];
  const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
  const digest = 'sha256=' + hmac.update(req.rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(digest));
}

app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; }
}));

app.post('/webhook', async (req, res) => {
  if (!verifySignature(req)) return res.sendStatus(401);

  const event = req.headers['x-github-event'];
  const { action, pull_request, repository } = req.body;

  if (event !== 'pull_request') return res.sendStatus(200);

  const prNumber = pull_request.number;
  const branch = pull_request.head.ref;
  const sha = pull_request.head.sha;
  const containerName = `preview-pr-${prNumber}`;
  const previewUrl = `https://pr-${prNumber}.${PREVIEW_DOMAIN}`;

  if (action === 'opened' || action === 'synchronize') {
    await deployPreview({ repository, branch, sha, containerName,
                          prNumber, previewUrl });
  } else if (action === 'closed') {
    await cleanupPreview({ containerName, repository, prNumber });
  }

  res.sendStatus(200);
});

async function deployPreview({ repository, branch, sha,
                                containerName, prNumber, previewUrl }) {
  const repoUrl = repository.clone_url;
  const buildDir = `${REPO_DIR}/${containerName}`;

  try {
    // Clone and build
    execSync(`rm -rf ${buildDir} && git clone --depth 1 \
      --branch ${branch} ${repoUrl} ${buildDir}`);
    execSync(`docker build -t ${containerName}:${sha} ${buildDir}`);

    // Stop old container if exists
    try { execSync(`docker stop ${containerName} && \
      docker rm ${containerName}`); }
    catch {}

    // Run new container
    execSync(`docker run -d --name ${containerName} \
      --network preview-net \
      -e NODE_ENV=preview \
      -e DATABASE_URL=postgresql://preview:preview@db:5432/pr_${prNumber} \
      --label "traefik.enable=true" \
      --label "traefik.http.routers.${containerName}.rule=\
Host(\`pr-${prNumber}.${PREVIEW_DOMAIN}\`)" \
      --label "traefik.http.routers.${containerName}.tls=true" \
      ${containerName}:${sha}`);

    // Comment on PR
    await octokit.issues.createComment({
      owner: repository.owner.login,
      repo: repository.name,
      issue_number: prNumber,
      body: `Deploy preview ready:\n${previewUrl}\n\nCommit: ${sha.slice(0, 7)}`
    });
  } catch (err) {
    console.error(`Preview deploy failed for PR #${prNumber}:`, err);
  }
}

async function cleanupPreview({ containerName, repository, prNumber }) {
  try {
    execSync(`docker stop ${containerName} && docker rm ${containerName}`);
    execSync(`docker rmi $(docker images ${containerName} -q) \
      2>/dev/null || true`);
    console.log(`Cleaned up preview for PR #${prNumber}`);
  } catch (err) {
    console.error(`Cleanup failed for PR #${prNumber}:`, err);
  }
}

app.listen(9090, () => console.log('Preview server running on :9090'));
```

### Step 2: Traefik as Dynamic Reverse Proxy

Traefik discovers containers automatically through Docker labels. No configuration reload needed when previews are created or destroyed.

```yaml
# docker-compose.yml
services:
  traefik:
    image: traefik:v3.2
    command:
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.le.acme.dnschallenge=true"
      - "--certificatesresolvers.le.acme.dnschallenge.provider=cloudflare"
      - "--certificatesresolvers.le.acme.email=you@yourapp.com"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    environment:
      - CF_API_EMAIL=you@yourapp.com
      - CF_DNS_API_TOKEN=your-cloudflare-token
    networks:
      - preview-net

networks:
  preview-net:
    external: true

volumes:
  letsencrypt:
```

### Step 3: GitHub Actions Alternative

If you'd rather use GitHub Actions instead of a self-hosted webhook handler, here's a workflow that builds and deploys via SSH:

```yaml
# .github/workflows/preview.yml
name: Deploy Preview

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

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

jobs:
  deploy:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and push image
        run: |
          docker build -t registry.yourapp.com/app:pr-${{ github.event.pull_request.number }} .
          docker push registry.yourapp.com/app:pr-${{ github.event.pull_request.number }}

      - name: Deploy to preview server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_KEY }}
          script: |
            docker pull registry.yourapp.com/app:pr-${{ github.event.pull_request.number }}
            docker stop preview-pr-${{ github.event.pull_request.number }} || true
            docker rm preview-pr-${{ github.event.pull_request.number }} || true
            docker run -d \
              --name preview-pr-${{ github.event.pull_request.number }} \
              --network preview-net \
              --label "traefik.enable=true" \
              --label "traefik.http.routers.pr-${{ github.event.pull_request.number }}.rule=Host(\`pr-${{ github.event.pull_request.number }}.preview.yourapp.com\`)" \
              registry.yourapp.com/app:pr-${{ github.event.pull_request.number }}

      - name: Comment PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `Preview deployed: https://pr-${context.issue.number}.preview.yourapp.com`
            })

  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_KEY }}
          script: |
            docker stop preview-pr-${{ github.event.pull_request.number }} || true
            docker rm preview-pr-${{ github.event.pull_request.number }} || true
```

This minimal setup covers the common case: one container per PR behind a wildcard subdomain. On a single server, the bottleneck is usually build time and memory, not request handling.

---

## What Are the Hard Problems with Preview Environments?

Running preview environments at scale introduces challenges that the basic setup doesn't address — and always-on previews that nobody is looking at are the most common source of waste.

### Database per Preview

Every preview needs its own data. You have three options, each with tradeoffs:

**Shared database, separate schemas.** Create a new PostgreSQL schema per PR (`CREATE SCHEMA pr_42`). Cheap and fast to provision. Downside: migrations run against every schema, and cross-schema pollution is possible if your app uses hardcoded schema references.

**Database per preview.** Spin up a fresh database container for each PR. Full isolation but heavy on resources -- each PostgreSQL instance uses 30-50MB of baseline RAM. With 20 open PRs, that's 600MB-1GB just for databases.

**Snapshot-based seeding.** Restore a database snapshot (pg\_dump) for each preview. Best of both worlds: isolated data without running extra database servers. The snapshot stays in sync with your seed script. This is what most mature teams settle on.

### Environment Variables

Each preview environment may need different API keys, OAuth redirect URLs, or service endpoints. Hardcoding environment variables in your deploy script works for five previews. It doesn't work for fifty.

The pattern that scales: store a `.env.preview` template in your repo with placeholder values. Your deploy script substitutes PR-specific values at container start time. Secrets stay in your CI/CD system's secret store, never in the repository.

### Resource Costs

A typical preview environment consumes 256MB-1GB of RAM. Multiply by 20 open PRs, and you need 5-20GB of RAM just for previews — always-on compute for environments that get visited a handful of times during code review.

Two mitigation strategies work well:

- **Scale-to-zero:** Stop containers after 5-10 minutes of inactivity and wake them on the next HTTP request, so idle previews stop holding memory. [How to implement scale-to-zero dev environments](https://temps.sh/blog/how-to-implement-scale-to-zero-dev-environments) covers the mechanics and the savings.
- **Max concurrent previews:** Set a cap (e.g., 10 active previews) and queue or skip older PRs. Most teams don't have more than 10 PRs in active review simultaneously.

### Orphaned Containers

PRs sometimes close without triggering the webhook. The author deletes their fork, GitHub has a webhook delivery failure, or your server was down when the close event fired. Over weeks, orphaned containers accumulate.

Build a garbage collector that runs daily: query the GitHub API for all open PRs, compare against running preview containers, and stop any container whose PR no longer exists. It's five lines of bash:

```bash
#!/bin/bash
# cleanup-orphans.sh -- run via cron daily
RUNNING=$(docker ps --filter "name=preview-pr-" --format "{{.Names}}" \
  | grep -oP '\d+')

for PR in $RUNNING; do
  STATE=$(gh pr view "$PR" --json state --jq '.state' 2>/dev/null)
  if [ "$STATE" != "OPEN" ]; then
    echo "Removing orphaned preview for PR #$PR"
    docker stop "preview-pr-$PR" && docker rm "preview-pr-$PR"
  fi
done
```

The biggest operational pain with DIY preview environments isn't any single challenge -- it's the compound maintenance burden. Database provisioning, orphan cleanup, secret management, SSL renewal, and resource monitoring each take 30 minutes to set up. But they each break independently, and debugging a failed preview deploy at 2am because the wildcard cert expired is exactly the kind of toil that makes teams abandon the system entirely.

---

## How Does Temps Handle Preview Environments?

Temps treats preview environments as a platform feature rather than a CI/CD bolt-on. Connect a GitHub (or GitLab) repository, turn on **Preview Environments** under the project's **Settings > Build & deploy > Previews**, and every push to a branch other than the main branch gets its own deployed environment at a unique subdomain. The [preview deployment docs](https://temps.sh/docs/set-up-preview-deployments) cover every setting.

### Automatic Per-Branch Deploys

When you push to a non-main branch, Temps:

1. Receives the push webhook from your Git provider
2. Creates a preview environment named after the branch if one doesn't exist (`feature/login` becomes `feature-login`)
3. Builds the app with the detected preset or your Dockerfile and deploys it to that environment
4. Serves it at `https://{project-slug}-{environment-slug}.{preview-domain}` — for example `https://my-app-feature-login.yourdomain.com`
5. If the branch has an open PR, posts a sticky comment and edits it in place as the deployment progresses — "Deploying preview", then the preview URL, or a deployment logs link if the build fails

Pushing the branch again redeploys to the same preview environment. Before the new version takes traffic it has to pass the same health checks as production, so a broken build never replaces a working preview.

No GitHub Actions workflow to write and no Traefik configuration. On the default preview domain every subdomain already resolves; if you serve previews from your own domain, add one wildcard DNS record (for example `*.yourdomain.com`) pointing at your Temps server.

### On-Demand Mode (Scale-to-Zero)

By default every preview environment runs 24/7, like production. Previews are natural candidates for scale-to-zero, though: they're opened briefly during review and sit idle for hours. Temps has an opt-in **On-Demand Preview Environments** setting: newly created previews stop after an idle timeout and wake on the next HTTP request.

The idle timeout defaults to 300 seconds (range 60–86,400) and the wake timeout to 30 seconds (range 5–120). When a sleeping preview gets a request, the proxy holds it, starts the container, and forwards the request once the container accepts connections; if the wake timeout expires first, the caller gets a 503. The setting only applies to previews created after you turn it on. For how much this saves and when it isn't worth it, see [scale-to-zero dev environments](https://temps.sh/blog/how-to-implement-scale-to-zero-dev-environments).

### Cleanup on PR Close

When you delete a branch — including GitHub's "automatically delete head branches" after a merge — Temps soft-deletes the auto-created preview environment, stopping its containers. You can also delete a preview at any time from the **Environments** page or with the CLI:

```bash
bunx @temps-sdk/cli environments delete feature-login --project my-app -f
```

If you keep branches after merging, call that command (or the equivalent API) from a GitHub Actions workflow on `pull_request.closed`. Long-lived environments you create by hand, such as `staging`, are never soft-deleted when their branch is removed.

### Notify Other Systems

Temps can also send deployment webhooks to your own services — for example to post the preview URL to Slack or trigger end-to-end tests against it. See the [webhooks docs](https://temps.sh/docs/webhooks) and [how to use deployment webhooks across the CI/CD lifecycle](https://temps.sh/blog/how-to-deploy-webhooks-ci-cd-lifecycle).

---

## FAQ

### How much does it cost to run preview environments?

On your own server, previews cost whatever server capacity they use. A Hetzner CX23 (2 vCPU / 4 GB RAM) is €5.49/month excluding VAT as of October 2026; how many previews fit depends on your app's memory footprint, and on-demand mode lets idle previews release their memory. On Vercel Pro you pay $20/month including one deploying seat, plus $20 per extra deploying seat and usage, so a team of five pays $100/month in seats. Netlify Pro is a flat $20/month per team, and Deploy Previews use no credits. Temps itself is free to self-host; Temps Cloud, a hosted control plane for your own servers, is coming soon from $29/month.

### Do preview environments need their own databases?

Yes, each preview should have isolated data to prevent test pollution. The most practical approach is snapshot-based seeding: restore a small pg\_dump for each preview that contains representative test data. This gives full isolation without running separate database servers. In Temps, environment variables are set per environment (one value per key per environment, with no override cascade), so a preview environment can be given its own database URL or schema.

### Can preview environments work with monorepos?

Yes. The key is building only the services that changed in the PR. GitHub Actions' `paths` filter handles this natively; for self-hosted setups, compare the changed file paths against your service directories and trigger builds selectively. In Temps, each app in the monorepo is its own project with an App Directory setting, and each project gets its own previews — see [deploying a monorepo with Temps](https://temps.sh/blog/deploy-monorepo-with-temps).

### Should I build preview environments myself or use a platform?

Build it yourself if you need full control over the pipeline and are prepared to maintain the webhook handler, wildcard certificates, orphan cleanup and resource limits described above. Use a platform if you'd rather turn previews on with a toggle; for a comparison of the options, see [the best platforms for preview deployments in 2026](https://temps.sh/blog/best-platforms-for-preview-deployments-2026), and for Temps vs Vercel specifically, [Temps vs Vercel](https://temps.sh/compare/vs-vercel) or [Temps vs Netlify](https://temps.sh/compare/vs-netlify).

### How do you handle authentication in preview environments?

OAuth providers require registered redirect URLs, which won't match dynamic preview subdomains. Three workarounds: use a wildcard redirect URL if your OAuth provider supports it, use a shared auth proxy that handles OAuth and forwards the session cookie, or bypass OAuth in preview environments using magic links or test credentials. Never disable authentication completely -- preview URLs are publicly accessible unless you protect them.

### How do I clean up preview environments automatically?

In a DIY setup, handle the `pull_request.closed` webhook to stop and remove the container, and run a daily garbage collector for previews whose close event was missed. In Temps, deleting the branch soft-deletes its auto-created preview; otherwise run `bunx @temps-sdk/cli environments delete <env> --project <project> -f` from a `pull_request.closed` workflow.

---

## Ship Every PR with Confidence

Preview environments remove the biggest bottleneck in code review: the setup friction. When every pull request comes with a live, clickable URL, reviews happen sooner, bugs get caught earlier, and stakeholders stay in the loop without scheduling demo calls.

You can build the system yourself. A webhook handler, Docker, Traefik and wildcard DNS give you the core functionality. The ongoing maintenance -- orphan cleanup, cert renewal, database provisioning, resource monitoring -- is where the real time cost lives.

If you'd rather skip the plumbing, install Temps on your server (see the [quickstart](https://temps.sh/docs/quickstart)):

```bash
curl -fsSL https://temps.sh/deploy.sh | bash
```

Connect your GitHub repo, turn on preview environments, push a branch, and watch the preview URL appear in your PR. Every push updates the sticky comment, and deleting the branch cleans the preview up. Self-hosted and free under MIT / Apache 2.0.

---

Source: https://temps.sh/blog/how-to-set-up-preview-environments-every-pull-request · Index of all pages: https://temps.sh/llms.txt
