all posts

Deploying a Next.js app to a microVM from git

Ajay Kumar··8 min read

The deploy story most teams want is boring: push to main, wait a bit, the new version is live, and if it isn't, the old one is still serving traffic. Vercel made that the baseline expectation for Next.js and everyone else has been catching up since. What varies enormously is what your code is actually running inside once the build finishes — a shared container runtime, a serverless function with a 250MB unzipped ceiling, or a machine of your own.

I'm Ajay, I build PandaStack, so treat the product bits as declared bias. But the interesting part here is mechanical: what has to happen between `git push` and a URL that serves HTML, when the target is a Firecracker microVM rather than a container. Roughly seven steps, and about three of them are where deploys die.

The shape of the pipeline

A deploy on PandaStack is a state machine with a twelve-minute budget. Nothing exotic, but the ordering matters more than it looks.

  1. Provision a fresh persistent microVM — the 'blue' side. Snapshot-restore, so this costs about 179ms at p50, not a container-pull.
  2. Shallow `git clone` the ref. Private repos get a GitHub App installation token minted on demand and never written to disk.
  3. Detect the framework: an explicit pin wins, then a `pandastack.json` in the repo, then auto-detection.
  4. Install the runtime with mise, honouring `.nvmrc`, `.python-version`, `.tool-versions`, or `mise.toml`.
  5. Run install and build, streaming logs into the deployment record as they happen.
  6. Start the process detached, then health-check its port until it answers — up to 60 seconds.
  7. Flip. The app's stable URL now points at the new VM, the previous deployment is marked superseded, and its VM is torn down.

The flip is the part worth dwelling on. Because the new VM is fully built and health-checked before anything routes to it, a failed build never touches production — you get a red deployment and a still-serving old version. That's blue-green, and it falls out for free when a machine is cheap enough to create per deploy. When a machine takes ninety seconds to boot, teams build in-place and pray instead.

Framework detection, and why you should override it

Auto-detection reads your `package.json` and repo layout and picks from `next`, `vite`, `cra`, `webpack`, `node`, `static`, `python`, or a generic fallback. For Next.js it's unambiguous — the dependency is right there — and you get `npm run build` then `npm start` with the port injected.

It's right most of the time and wrong in exactly the ways you'd expect: monorepos where the interesting `package.json` is two directories down, apps that build with one tool and serve with another, repos where `start` runs a dev server. So detection results get written back as pins on the app. Once a deploy has resolved your framework, that decision sticks rather than being re-guessed on every push — which means a repo that changes shape doesn't silently change deploy strategy at 2am.

If you know what you want, say so and skip the guessing entirely.

// pandastack.json, committed at the repo root.
// Explicit beats clever: this is read before auto-detection runs.
{
  "framework": "next",
  "install": "npm ci",
  "build": "npm run build",
  "start": "npm run start -- --port $PORT --hostname 0.0.0.0",
  "port": 3000
}
Bind to 0.0.0.0, not localhost. The health check and the reverse proxy both reach your process over the guest's network interface, so a server listening only on 127.0.0.1 answers from inside the VM and nowhere else. This is the single most common 'but it works locally' deploy failure, on every platform, not just this one.

Runtime versions: mise, not a base image per language

There's one universal `base` template — Ubuntu 24.04 with mise and pre-warmed Node, Python, Go, and Bun — rather than a matrix of per-language images. Your repo declares what it wants using the file it already has, and `mise install` resolves it at deploy time. An `.nvmrc` with `22` gets Node 22 whether or not the template shipped with it.

Two operational details that cost me time and will cost you the same. Build steps run as non-login `sh -c`, so `MISE_DATA_DIR`, `MISE_CONFIG_DIR`, and the shims directory on `PATH` are exported explicitly before each command — a login shell's profile never runs. And after installing dependencies, a `mise reshim` is needed before console scripts like `uvicorn` or `gunicorn` are visible: pip and npm drop them into the runtime's own `bin`, not into the shim directory.

The build box is the runtime box, and that's a memory problem

Here's a constraint specific to snapshot-restore that I'd rather you learn from a blog post than from a failed deploy. Firecracker cannot change vCPU count or RAM at restore time. Those are properties of the snapshot. So an app's memory comes from the baked `base` template — currently 4 GiB — and not from a per-app `memory_mb` field you set in the UI.

That number isn't arbitrary. It's 4 GiB because 2 GiB wasn't enough: Next.js builds, `tsc` on a large project, and Vite production builds all OOM below it, and an OOM during `next build` produces a build log that ends mid-sentence with exit code 137 and no explanation. If you see a truncated log and a 137, you are not looking at a code bug, you are looking at the Node heap hitting the ceiling. Trimming what gets type-checked at build time, or setting `NODE_OPTIONS=--max-old-space-size` deliberately rather than letting the default fight the cgroup, is the fix.

What it looks like in practice

Create the app once, then deploys are either a push or one API call. The REST surface is small enough to drive from a shell script or a CI job without an SDK.

# Create an app from a repo. Framework omitted => auto-detected on first deploy.
curl -sS -X POST https://api.pandastack.ai/v1/apps \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "acme-web",
    "git_url": "https://github.com/acme/web",
    "git_branch": "main",
    "auto_deploy": true,
    "env": { "NEXT_PUBLIC_API_BASE": "https://api.acme.example" }
  }'

# Trigger a deploy by hand, pinned to a commit (CI does this on merge).
curl -sS -X POST https://api.pandastack.ai/v1/apps/$APP_ID/deploys \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"git_ref": "'"$GITHUB_SHA"'"}'

# Follow the build. Server-sent events, so this streams as the build runs.
curl -N -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  https://api.pandastack.ai/v1/apps/$APP_ID/deploys/$DEPLOY_ID/logs

# After the flip: your app's own stdout/stderr, not the hypervisor's log.
curl -N -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  "https://api.pandastack.ai/v1/apps/$APP_ID/runtime-logs?follow=1"

Those last two endpoints are different things and mixing them up wastes an afternoon. Build logs belong to a deployment and stop when it finishes. Runtime logs are your process's stdout and stderr, tailed out of the guest. Neither is the Firecracker console log, which records the hypervisor's view and is almost never what you want when debugging a 500.

Rollback is the feature you actually buy

Every deploy leaves a record: commit, ref, status, build logs, the sandbox it ran on. Rolling back re-points the app at a previous deployment. What makes that trustworthy rather than aspirational is that the previous deployment was a whole machine image — kernel, filesystem, installed dependency tree, built artifact — not a config pointer into a shared runtime that has since been upgraded underneath you.

A health monitor runs alongside on a 30-second reconcile loop, checking each running app and restarting it after two consecutive failures, with a lifetime cap so a genuinely broken app parks in an error state instead of crash-looping forever and generating a five-figure log bill. The cap matters: infinite automatic restarts is how a bad deploy turns into an incident that lasts all weekend.

When this is the wrong tool

If your Next.js app is mostly static with a handful of edge-cached routes and you're happy on a CDN-first platform, this buys you nothing — you'd be trading a global edge network for a machine in one region. Genuine edge rendering is a different architecture and I'm not going to pretend a VM in one region competes on first-byte latency in Sydney.

Where a VM wins is everything that isn't a request handler: long-running background work, WebSocket connections that outlive a function timeout, a native binary or a system package you need installed, a build that wants more memory than a function ever gets, and processes that hold state between requests. Plus the plain fact that your app's neighbours are separated by a hypervisor rather than a namespace. If none of that describes your app, stay where you are — that's a real answer, not a polite one.

Frequently asked questions

How does a git-driven deploy to a microVM avoid downtime?

Each deploy provisions a brand-new microVM, clones the repo, installs the runtime, builds, starts the process, and health-checks its port before anything routes to it. Only after the health check passes does the app's stable URL flip to the new VM, at which point the previous deployment is marked superseded and its VM is torn down. A failed build or a process that never answers its health check never receives traffic — the old version keeps serving. This blue-green pattern is practical here because creating a machine is a sub-second snapshot-restore rather than a multi-minute boot, so you can afford a fresh one per deploy.

Why does my Next.js build fail with exit code 137?

137 is the process being killed for exceeding available memory — almost always the Node heap during `next build` or `tsc`. Because Firecracker cannot change RAM at snapshot-restore time, an app's memory is a property of the baked base template (currently 4 GiB) rather than a per-app setting. The build and the runtime share that machine. The fix is to reduce build-time memory pressure: check what is being type-checked during the build, and set NODE_OPTIONS=--max-old-space-size explicitly rather than letting Node's default heuristic fight the memory limit. A build log that stops mid-sentence with no error message is the signature.

How do I control which Node or Python version my app builds with?

Commit the idiomatic file for your ecosystem — .nvmrc, .python-version, .tool-versions, or mise.toml — and mise resolves it at deploy time. There is one universal base template rather than an image per language version, so switching from Node 20 to Node 22 is a one-line repo change, not a platform migration. Two gotchas: build commands run as non-login shells, so mise's environment is exported explicitly rather than inherited from a profile; and after installing dependencies you need a reshim before console scripts such as uvicorn or gunicorn appear on PATH, because pip and npm install them into the runtime's own bin directory.

What is the difference between build logs and runtime logs?

Build logs belong to a single deployment. They stream the install and build commands as they run and stop when the deployment finishes, and they are what you read when a deploy goes red. Runtime logs are your application process's own stdout and stderr, captured to a file inside the guest and tailed back out, and they are what you read when the app is live but returning errors. A third thing exists and is rarely what you want: the Firecracker console log, which is the hypervisor's view of the VM — useful for boot problems, useless for a 500 from your handler.

Should I use a microVM instead of a serverless platform for Next.js?

Not if your app is mostly static pages and cache-friendly routes and you are already happy on a CDN-first platform — a single-region VM cannot match a global edge network on first-byte latency, and pretending otherwise would be dishonest. A microVM earns its place when you need things a request-scoped function is bad at: background jobs that outlive a request, WebSocket connections, native binaries or system packages, builds that need real memory, in-process state between requests, or genuine kernel-level isolation from other tenants. Pick based on which list your app is actually on.

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.