How Do I Get a Live Preview URL for Every Pull Request?
Updated October 27, 2026 .md
Short answer: Have CI create one small sandbox per pull request, labelled with the PR number. It checks out the PR head, builds, serves the app and runs sbx host for an HTTPS URL you post on the PR. Idle previews pause and wake on the next visit. When the PR closes, find the sandbox by label and delete it.
At a glance#
| PR event | What the workflow does | Command |
|---|---|---|
| Opened | Create a small sandbox, label it preview=<app>,pr=<n> |
sbx new --no-env --idle 15, then PATCH /sandboxes/{id} with labels |
| Opened or new push | Check out pull/<n>/head, install, build, serve, host the port |
sbx exec, systemd-run, sbx host $id 8080 |
| Nobody looks at it | The sandbox pauses with its memory; compute isn't billed | idleStopMinutes (15 here) |
| A reviewer opens the link | The request wakes the sandbox and is then served | nothing to run |
| Closed or merged | Find the sandbox by its labels and delete it | GET /sandboxes?labels=…, sbx delete --yes |
We ran both scripts below on 2026-10-27 against sveltejs/template, a small public web app, using its PR #291 (it changes the asset paths in index.html to relative ones, which makes it easy to see that the preview serves the PR and not master). Tokens are replaced with <token>. We did not run the GitHub Actions side; the workflow at the end calls the same two scripts.
The "PR opened or updated" script#
Save this as scripts/preview-up.sh. It needs sbx, curl, jq and SANDBOXER_API_KEY in the environment. Labels are set and searched through the REST API (labels); the small api helper passes the key on stdin so it never shows up in the process list.
#!/usr/bin/env bash
# preview-up.sh <repo-url> <pr-number>: start or refresh one pull request's preview and print its URL.
set -euo pipefail
repo="$1"; pr="$2"; app="${PREVIEW_APP:-web}"
API=https://sandboxer.com/api/v1
api() { printf 'Authorization: Bearer %s\n' "$SANDBOXER_API_KEY" |
curl -fsS -H @- -H 'Content-Type: application/json' "$@"; }
# 1. Find this PR's sandbox by its labels, or create one and label it.
id=$(api "$API/sandboxes?labels=preview=$app,pr=$pr" | jq -r '.sandboxes[0].id // empty')
if [ -z "$id" ]; then
id=$(sbx new --name "${NAME_PREFIX:-preview-pr-}$pr" --type small --no-env --idle 15 --ttl 604800 --json |
jq -r 'select(.event == "ready") | .id')
api -X PATCH "$API/sandboxes/$id" -d "{\"labels\":{\"preview\":\"$app\",\"pr\":\"$pr\"}}" >/dev/null
else
sbx resume "$id" >/dev/null 2>&1 || true # only needed after auto-stop; idle pauses wake on their own
fi
# 2. Check out the PR head, install and build.
sbx exec "$id" --timeout 600 "sudo systemctl stop preview 2>/dev/null; rm -rf app &&
git clone --quiet $repo app && cd app &&
git fetch --quiet origin pull/$pr/head:pr && git checkout --quiet pr &&
npm install --silent && npm run build --silent" >&2
# 3. Serve it as a systemd unit, not a detached API command, so idle auto-pause can kick in.
sbx exec "$id" "sudo systemd-run --quiet --collect --uid=user --working-directory=/home/user/app \
--unit=preview npm run start -- --host 0.0.0.0 --port 8080" >&2
# 4. Give port 8080 its HTTPS URL (private: it carries a _token).
sbx host "$id" 8080 --title "PR #$pr" --json | jq -r .urlWhy these flags:
--no-envgives the sandbox none of your account's secrets or credentials. PR code is untrusted; it gets a clean machine.--type small(2 vCPU, 4 GB) is enough to build and serve most front ends. Usedefaultfor heavier builds.--idle 15pauses the preview after 15 idle minutes.--ttl 604800is a 7-day hard stop in case the "closed" event never arrives.GET /sandboxes?labels=k=v,k2=v2returns sandboxes carrying all those labels, in every state, so a second push finds the same sandbox.
First run (PR opened):
PREVIEW_APP=sveltejs-template ./preview-up.sh https://github.com/sveltejs/template 291src/main.js → public/build/bundle.js...
created public/build/bundle.js in 545ms
https://coastal-eager-oak-8080.box.sandboxer.com?_token=<token>
real 0m13.891sCreate, clone, install, build, serve and host took 14 seconds end to end (single run, October 27, 2026). Then we checked the URL:
curl -sS -o /dev/null -w "%{http_code}\n" https://coastal-eager-oak-8080.box.sandboxer.com/ # 401
curl -sSL -c jar -b jar "https://coastal-eager-oak-8080.box.sandboxer.com?_token=<token>" | grep href <link rel='icon' type='image/png' href='./favicon.png'>
<link rel='stylesheet' href='./global.css'>
<link rel='stylesheet' href='./build/bundle.css'>Without the token the proxy answered 401. With it, the first response was a 302 that set a cookie, and the page carried the PR's relative ./ paths (master has /global.css).
Second run with the same PR number (a new push): the script found the sandbox by label, rebuilt, and printed the same URL and token in 5.8 seconds. The page answered 200 afterwards.
sbx list --all --json | jq -c '.sandboxes[] | select(.labels.pr=="291") | {id,name,type,idleStopMinutes,noEnv,labels}'{"id":"sb_i5kr6c0ha2","name":"aeo-preview-pr-291","type":"small","idleStopMinutes":15,"noEnv":true,"labels":{"pr":"291","preview":"sveltejs-template"}}Private or public URL#
sbx host returns a private URL by default. Anyone with the full URL can open it, so posting it in a PR comment shares it with everyone who can read the PR. For a public repository, or for previews you want to link from anywhere, make it public:
sbx host sb_i5kr6c0ha2 8080 --public{"url":"https://coastal-eager-oak-8080.box.sandboxer.com","access":"public"}The same hostname then answered 200 without a token. The URL keeps its hostname across pauses and resumes, so the link in the PR stays valid for the life of the preview.
The "PR closed" script#
Save this as scripts/preview-down.sh. GitHub sends closed for both merged and unmerged PRs.
#!/usr/bin/env bash
# preview-down.sh <pr-number>: delete that pull request's preview sandbox.
set -euo pipefail
app="${PREVIEW_APP:-web}"
API=https://sandboxer.com/api/v1
api() { printf 'Authorization: Bearer %s\n' "$SANDBOXER_API_KEY" |
curl -fsS -H @- -H 'Content-Type: application/json' "$@"; }
api "$API/sandboxes?labels=preview=$app,pr=$1" | jq -r '.sandboxes[].id' |
while read -r id; do sbx delete "$id" --yes; done{"ok":true,"type":"deletion.operation","operation":{…,"stage":"completed","status":"completed","targetId":"sb_i5kr6c0ha2"}}Running it a second time found nothing and exited 0, so a re-delivered event is harmless. The preview hostname answered 404 after the delete.
Stop previews when nobody is looking#
A sandbox with idleStopMinutes set pauses itself after that many minutes without activity. A pause keeps files and, when the wake lands on a machine with the same CPU type, memory. Otherwise it boots from disk with the files kept but the server stopped, because a systemd-run unit is transient; the next preview-up.sh run starts it again. While it's paused, compute isn't billed, and the next request to a hosted URL wakes it: the request is held while it resumes (up to 45 seconds) and then served. See idle auto-pause and wake on use.
Two things we hit while testing with sbx idle <id> 1:
- Don't serve with
sbx exec --detach. A detached command counts as a running command, and that keeps the sandbox awake. With the dev server started that way, the sandbox stayedreadyfor over 3 minutes on a 1-minute idle setting. That's why the script starts the server withsystemd-run, which the API doesn't track as a command. - Once the server ran under systemd, the pause worked. The last activity was at 02:31:11 UTC and the sandbox was paused (
"reason":"idle") at 02:32:38.
Then we woke it through the preview URL (this was our first test sandbox, so the hostname differs):
curl -sS -o /dev/null -w "%{http_code} in %{time_total}s\n" https://curious-rare-cedar-8080.box.sandboxer.com/
curl -sSL -c jar -b jar -o /dev/null -w "%{http_code} in %{time_total}s\n" "https://curious-rare-cedar-8080.box.sandboxer.com?_token=<token>"503 in 0.194514s
200 in 12.713457sA request without the token got 503 and didn't wake the paused sandbox; that's expected for a private port. The request with the token woke it and was served in 12.7 seconds, and the next request took 0.2 seconds. A public port wakes for any request.
Auto-stop (the --ttl deadline) is different: a sandbox stopped that way doesn't wake by itself. The script resumes it on the next push, and a reviewer can run sbx resume <id>.
What a preview costs#
Billing is per second while the sandbox runs. A paused or stopped sandbox costs nothing for compute; its disk counts toward your plan's included storage, which is why the closed step deletes it.
| Preview | Size | Running time | Cost |
|---|---|---|---|
| Kept running for a PR open 3 days | small at $0.018/hour |
72 hours | about $1.30 |
| Same PR, 15-minute idle pause, opened 6 times for 10 minutes | small at $0.018/hour |
about 2.5 hours (each visit plus the 15-minute tail) | about $0.05 |
| Same, heavier app | default at $0.036/hour |
about 2.5 hours | about $0.09 |
sbx usage <id> shows what one sandbox used. Ours, 33 seconds after creation (seconds is already scaled by the small multiplier of 0.5):
{"billingMultiplier":0.5,"dollars":0.00016,"running":true,"sandboxType":"small","seconds":16,…}The GitHub Actions workflow#
This is the workflow that would call the two scripts. We did not run it on GitHub; the scripts it calls are the ones tested above. Add an API key as the repository secret SANDBOXER_API_KEY (the ci preset covers create, resume and delete-own; see API keys).
name: PR preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
permissions:
contents: read
pull-requests: write
concurrency:
group: preview-${{ github.event.pull_request.number }}
env:
SANDBOXER_API_KEY: ${{ secrets.SANDBOXER_API_KEY }}
PREVIEW_APP: ${{ github.event.repository.name }}
PR: ${{ github.event.pull_request.number }}
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install sbx
run: |
mkdir -p "$HOME/.local/bin"
curl -fsSL https://sandboxer.com/dl/sbx-linux-amd64 -o "$HOME/.local/bin/sbx"
chmod +x "$HOME/.local/bin/sbx"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Start or refresh the preview
if: github.event.action != 'closed'
env:
GH_TOKEN: ${{ github.token }}
run: |
url=$(scripts/preview-up.sh "${{ github.server_url }}/${{ github.repository }}" "$PR")
echo "::add-mask::$url"
gh pr comment "$PR" --edit-last --body "Preview: $url" ||
gh pr comment "$PR" --body "Preview: $url"
- name: Delete the preview
if: github.event.action == 'closed'
run: scripts/preview-down.sh "$PR"concurrencystops a push and a close for the same PR from racing.::add-mask::keeps the private URL out of the job log; it is still in the PR comment.- For pull requests from forks, GitHub doesn't pass repository secrets to the workflow (GitHub docs), so this workflow only previews branches in your own repository unless you change how it is triggered.
Adapting it to your app#
- Vite, Next.js and similar: replace the build-and-serve lines with your app's commands, and bind to
0.0.0.0. If the server only listens on127.0.0.1, runsbx host $id <port> --localhostinstead. Vite rejects unknown hosts unless.box.sandboxer.comis inserver.allowedHosts(see Hosting). - Faster installs: install dependencies once in a sandbox, save it with
sbx snapshot <id> app-main, and create previews withsbx new --from app-mainso each PR only installs what changed. - Private repositories:
--no-envleaves out your saved git credentials, so pass a read-only token for that repository with-eand clone with it.
FAQ#
Does every push create a new sandbox?#
No. The script looks the sandbox up by its preview and pr labels and reuses it, so a push rebuilds in place and the URL and token stay the same. In our single test run the second run took 5.8 seconds.
What does a reviewer see if the preview is paused?#
The page loads slower the first time. The request is held while the sandbox resumes and is then served; ours took 12.7 seconds in a single run. If a resume takes longer than 45 seconds the proxy answers 503 with Retry-After.
Why not keep the dev server running with sbx exec --detach?#
A detached command keeps the sandbox awake, so it never pauses for being idle. Run the server as a systemd unit, as the script does. The API doesn't count a systemd unit as a running command, so the sandbox can still pause.
Can PR code read my Sandboxer account or secrets?#
Not with --no-env. The sandbox starts without your account's secrets or credentials and without an in-sandbox API key, and each sandbox is its own microVM.
What happens if the "closed" event is missed?#
The 7-day --ttl stops the sandbox, which ends compute billing. It still uses storage until it is deleted, so running preview-down.sh for old PR numbers, or listing sandboxes with the preview label, is a good weekly cleanup.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.