all posts

Preview Environments on microVMs: a Live URL per PR

Ajay Kumar··9 min read

A preview environment is a live, running copy of your app spun up for a single pull request, reachable at a stable URL so a reviewer, a designer, or a product manager can click around the actual change before it merges. Vercel and Netlify made this table stakes for frontends — push a branch, get a URL. The hard part is doing it for a full-stack app: a real backend, a real database, background workers, and code that may have come from a forked PR you don't control. This post is about building that — a Vercel/Render-style preview per PR, but for full-stack apps — on a Firecracker microVM so each preview is genuinely isolated, comes up fast, and tears itself down when the PR closes. I'm Ajay; I built PandaStack, and I'll be honest about where the model helps and where it doesn't.

Why full-stack preview environments are hard

Frontend previews are easy because the artifact is static and the blast radius is zero — it's HTML and JS on a CDN. The moment your preview needs to run server code, the problem changes shape. Now you're booting a process that opens ports, connects to a database, reads secrets, and runs whatever the branch author wrote. Teams usually reach for one of two patterns, and both have a sharp edge.

The first pattern is a shared staging environment that everyone redeploys onto. It's cheap, but it's a single mutable target — two PRs in flight stomp on each other, a bad migration from one branch corrupts the data the next reviewer sees, and "works on the preview" stops meaning anything because the preview is whatever was deployed last. The second pattern is one container per PR on a shared Kubernetes cluster. Better isolation on paper, but containers share the host kernel: a forked-PR build script that runs `npm install` on a malicious package, or a test that allocates 40GB, or a postinstall hook that reads the node's service-account token, is now a problem for every neighbor on that node.

A preview environment that shares a kernel with prod — or with another PR's preview — is just prod with extra steps. If a forked PR can run arbitrary build and test code (it can), the boundary between previews has to be a real one.

What a good preview environment actually needs

Strip away the tooling and a per-PR preview has three requirements, and they pull in different directions:

  • Isolation — one PR's preview must not be able to read, corrupt, or take down another's, and forked-PR code (build scripts, postinstall hooks, tests) is untrusted by definition. Shared-kernel containers give you namespaces, not a security boundary.
  • A stable URL — the same link has to work for the life of the PR, survive every new push to the branch, and be something you can paste into a Slack thread or a GitHub comment without it rotting an hour later.
  • Speed and cost — a preview that takes five minutes to come up kills the review loop, and a preview that costs a full VM's idle spend per open PR doesn't scale past a handful of them. You want fast spin-up and near-zero cost when nobody's looking at it.

Containers nail speed but not isolation. Full VMs nail isolation but historically lose on speed and cost — nobody wants to wait two minutes for a VM to boot per push, or pay for it sitting idle. A microVM is the combination that didn't used to exist: hardware isolation with sub-second restore.

Why a microVM per preview

A Firecracker microVM boots its own guest kernel and is confined by hardware virtualization (KVM) — the same VMM AWS Lambda and Fargate use to run untrusted code from millions of customers. Each preview is its own VM with its own kernel, its own filesystem, and its own network namespace. A forked PR's build script can `rm -rf /`, fork-bomb, or try to read a service-account token, and the blast radius is one disposable VM, not the node and its neighbors. That's the isolation requirement, satisfied by the substrate rather than by hoping the container runtime holds.

The historical objection to VMs was startup. Firecracker answers it with snapshot-restore: instead of cold-booting a guest kernel on every preview, the platform restores a baked snapshot of an already-booted machine. On PandaStack a create is p50 179ms (p99 ~203ms), of which the snapshot-restore step itself is around 49ms — because every create restores a snapshot on demand rather than booting from scratch. The first cold boot of a brand-new template is ~3s, but that happens once; after that you're on the fast path. So a preview environment that's a real VM comes up about as fast as a container would, and you get the isolation for free.

There's a second, less obvious win: forking. Because the rootfs is copy-on-write (reflink) and memory restores copy-on-write too, you can snapshot a fully-provisioned preview — dependencies installed, database seeded — and fork it per PR. A same-host fork is 400–750ms and shares memory copy-on-write; a cross-host fork (downloading the snapshot from object storage first) is 1.2–3.5s. That turns "set up the whole environment from scratch" into "clone a known-good one," which is exactly the shape you want when twenty PRs need the same baseline.

How PandaStack's git-driven app hosting does it

PandaStack ships a git-driven app hosting feature that's built for exactly this: connect a GitHub repo, and it builds your app inside a persistent microVM on the language-agnostic `base` template, then serves it behind a stable per-app URL. The build pipeline shallow-clones the ref, auto-detects the framework (Next, Vite, plain Node, static, Python, and more), resolves runtime versions from idiomatic files (`.nvmrc`, `.python-version`, `.tool-versions`, `mise.toml`) via mise, runs install and build with logs streamed to the deployment, starts the process, and health-checks the port before flipping traffic to it.

The two pieces that make it a preview platform rather than just a host are the stable URL and the blue-green flip. Each app gets a stable host — the app's UUID is the bearer credential in the hostname — that survives every redeploy. And deploys are blue-green: a new deploy provisions a fresh sandbox (the "blue"), builds and health-checks it in full, and only then atomically points the app's URL at the new VM and tears down the old one. A failed build never takes the live preview down; the reviewer keeps seeing the last good version until the new one is provably healthy. An HMAC-verified GitHub push webhook triggers a deploy pinned to the exact commit, so every push to the PR's branch redeploys the preview without a human in the loop.

Each preview is a real Firecracker microVM with its own guest kernel and network namespace. Mapping "one app per PR" onto "one app per microVM" means the isolation between PR previews is hardware-enforced, not a container-runtime promise.

Wiring a preview to each pull request

If you want the lower-level control — one preview microVM per PR, your own build steps — the SDK gives you the primitives directly. The shape is: on PR open, create a sandbox on the `base` template, clone the branch, install and build, start the server, and post the preview URL back to the PR. Set a `ttl_seconds` so a forgotten preview reaps itself even if the close hook never fires.

from pandastack import Sandbox

# One preview microVM per pull request. The base template is the
# language-agnostic apps runtime (Node, Python, Go, Bun via mise).
# ttl_seconds is a backstop so a forgotten preview reaps itself.
sbx = Sandbox.create(template="base", ttl_seconds=86400, persistent=True)
try:
    # Clone the exact PR branch (untrusted forked-PR code is fine here —
    # it's confined to this VM's kernel, fs, and network namespace).
    sbx.exec(f"git clone --depth 1 -b {pr_branch} {repo_url} /app",
             timeout_seconds=120)

    # Install + build. mise resolves runtime versions from .nvmrc /
    # .python-version / .tool-versions in the repo.
    sbx.exec("cd /app && npm install && npm run build",
             timeout_seconds=600)

    # Start the server detached; it keeps running after exec returns.
    sbx.exec("cd /app && setsid sh -c 'PORT=3000 npm start "
             "> /var/log/app.log 2>&1' &", timeout_seconds=10)

    # Reachable at a tokenless preview URL for the VM's lifetime:
    #   https://3000-<sandbox-id>.<preview-suffix>
    print("preview:", f"https://3000-{sbx.id}.preview.pandastack.ai")
except Exception:
    sbx.kill()   # build failed — don't leak the VM
    raise

On the preview URL: a sandbox port is reachable at `https://<port>-<id>.<suffix>` for the sandbox's lifetime, with the sandbox UUID acting as the credential — no token endpoint to call. That's your stable link to drop into the PR. Because each preview is its own VM, you can give the PR a real backend (start the API alongside the web server) and a real database without two previews ever sharing state.

A real database per preview

"Full-stack" usually means "needs a database," and a shared database is where preview isolation quietly dies — one PR's migration breaks the next reviewer's session. PandaStack's managed Postgres gives each preview its own database VM with its own durable volume, so a destructive migration in one PR can't touch another's data. Database create is 30–90s (it blocks until Postgres is actually ready), so for fast review loops it's worth seeding a baseline once and forking it, or provisioning the DB in parallel with the build rather than serially.

# CI step: open a per-PR preview on push, via the GitHub webhook path.
# PandaStack's git-driven hosting verifies the HMAC and deploys the
# exact pushed commit; blue-green means the live URL only flips once
# the new build passes its health check.

# 1. One-time: connect the repo so pushes auto-deploy the preview.
curl -sS -X POST https://api.pandastack.ai/v1/apps \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-preview",
    "git_url": "https://github.com/acme/web",
    "git_branch": "feature/checkout-v2",
    "template": "base",
    "auto_deploy": true
  }'

# 2. Read back the stable preview URL to post on the PR.
curl -sS https://api.pandastack.ai/v1/apps/$APP_ID \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" | jq -r '.url'

Tear-down on PR close (and why it matters)

Preview environments leak money the way unclosed file handles leak memory — quietly, until you're paying for forty stale VMs from PRs that merged weeks ago. The discipline is to tie the preview's lifecycle to the PR's lifecycle: create on open, redeploy on push, destroy on close or merge. With the SDK that's a one-liner in your `pull_request: closed` webhook handler; with git-driven hosting, deleting the app tears down its runtime sandbox for you.

from pandastack import Sandbox

# GitHub 'pull_request: closed' webhook handler. Look up the sandbox
# you stashed when the PR opened and destroy it. Idempotent: if it's
# already gone (TTL reaped it), that's fine.
def on_pr_closed(pr_number: int, sandbox_id: str) -> None:
    try:
        Sandbox.get(sandbox_id).kill()
    except Exception:
        pass  # already reaped by ttl_seconds — nothing to do
    print(f"tore down preview for PR #{pr_number}")

Two backstops keep this honest. The `ttl_seconds` you set on create means a preview reaps itself even if the close webhook is dropped or your handler 500s — belt and suspenders. And if you'd rather keep a preview warm across a quiet afternoon without paying for an idle VM, hibernate it: a snapshot-and-pause stops the VM, and the next request to the URL auto-wakes it. Idle previews cost close to nothing, which is what makes "a preview per open PR" actually affordable instead of a line item someone questions every quarter.

When a microVM preview is overkill

Be honest about the threshold. If your previews are purely static frontends from first-party branches you trust, a CDN-based preview (Vercel, Netlify, Cloudflare Pages) is simpler and you don't need a VM per PR — the blast radius is already zero. The microVM model earns its keep when the preview runs server code, when it needs a real database, or when the code can come from a forked PR you don't control. That's the line: the moment a preview is a running, stateful, potentially-untrusted thing rather than a bundle of static files, shared-kernel isolation stops being good enough and a microVM-per-preview becomes the boundary that lets you hand reviewers a real, isolated copy of the whole app without betting the cluster on a stranger's `postinstall` script.

Frequently asked questions

What is a per-PR preview environment?

It's a live, running copy of your app spun up automatically for a single pull request and reachable at a stable URL, so reviewers can click through the actual change before it merges. For frontends this is a CDN deploy; for full-stack apps it means a real backend and database. On PandaStack each preview is its own Firecracker microVM, so previews are isolated by hardware and can run untrusted forked-PR code safely.

Why use a microVM instead of a container for preview environments?

Containers share the host kernel, so a forked PR's build script, postinstall hook, or runaway test can affect neighboring previews on the same node — namespaces are isolation, not a security boundary. A Firecracker microVM boots its own guest kernel under hardware virtualization (KVM), confining each preview to one disposable VM. Snapshot-restore keeps it fast: a create is p50 179ms, so you get VM-grade isolation at roughly container speed.

How does the preview URL stay stable across new commits?

PandaStack's git-driven app hosting gives each app a stable per-app URL where the app's UUID is the bearer credential, and deploys are blue-green: a new push builds a fresh microVM, health-checks it, and only then atomically flips the URL to the new VM and tears down the old one. The same link works for the life of the PR, survives every redeploy, and a failed build never takes the live preview down.

How do I tear down a preview environment when a PR closes?

Tie the preview's lifecycle to the PR: create on open, redeploy on push, destroy on close. In a 'pull_request: closed' webhook handler, look up the sandbox and call kill() (or delete the app, which tears down its runtime sandbox). As a backstop, set ttl_seconds on create so a forgotten preview reaps itself, and hibernate idle previews so an open PR nobody's looking at costs close to nothing.

Can each preview environment have its own database?

Yes. PandaStack's managed Postgres gives each preview its own database VM with its own durable volume, so a destructive migration in one PR can't corrupt another reviewer's data — the failure mode of shared staging databases. Database create takes 30–90s because it blocks until Postgres is ready, so for fast loops it's best to seed a baseline once and fork it, or provision the database in parallel with the build.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.