all posts

How to migrate from Vercel to microVM hosting

Ajay Kumar··9 min read

This is the version of the migration I'd actually run: prove portability locally first, move the app while keeping a rollback, and deal with the invisible pieces — CDN, image optimisation, ISR cache — explicitly rather than discovering them in production.

The destination here is a microVM: a hardware-isolated virtual machine with its own kernel, running your app as a long-lived process. I build PandaStack, so the commands are mine; the sequence works for any long-lived-process platform and I've written it that way.

Step 0: decide whether you should

Worth thirty minutes before you spend two weeks. MicroVM hosting is the right destination when you need a long-lived process (background work, WebSockets, in-memory state), when the isolation boundary matters because you run code you didn't write, or when your bill is dominated by function invocations rather than bandwidth.

It's the wrong destination if your bill is mostly bandwidth on a mostly-static site — put a CDN in front and stay — or if you use bleeding-edge Next.js features the week they ship.

Step 1: prove the app runs as a plain Node server

Do this locally before you touch any platform. If it doesn't work here, no hosting decision will fix it.

# next.config.js
#   module.exports = { output: 'standalone' }

npm run build

# The standalone build needs static assets copied alongside it —
# this is the step everyone misses, and it produces a site with no CSS
cp -r public .next/standalone/public
cp -r .next/static .next/standalone/.next/static

PORT=3000 node .next/standalone/server.js
# Open it. If CSS and images are missing, the copy step above is why.
The standalone output deliberately excludes `public/` and `.next/static/`, because on Vercel those are served by the CDN. Locally and on any other host, you must copy them next to the server or the app renders unstyled with broken images — a failure that looks catastrophic and is two lines of build script.

Step 2: sort out environment variables by timing

Split your variables into two lists: those needed during the build and those needed at runtime. Anything named NEXT_PUBLIC_* is inlined into the client bundle at build time, so if the build doesn't have it, the browser gets undefined — regardless of how correct the runtime configuration looks.

Do this as an explicit inventory. It's the single most common cause of a migration that appears to work and then fails in the browser for one feature.

Step 3: deploy the app, pointed at production data, taking no traffic

# Deploy from git. Install, build, and start are detected;
# override the start command when you use the standalone output.
pandastack apps create --name web \
  --git-url https://github.com/acme/web \
  --git-branch main \
  --build-cmd 'npm ci && npm run build && cp -r public .next/standalone/public && cp -r .next/static .next/standalone/.next/static' \
  --start-cmd 'node .next/standalone/server.js'

# Watch the build; the first one is where surprises live
pandastack apps logs web --follow

Point it at the same database and services production uses. Read paths are safe; you're validating that the app works, not cutting traffic over. Nothing about DNS changes yet.

Step 4: replace the invisible pieces

The CDN

Vercel's CDN was serving every static asset and cached page. Put one in front of your new origin before cutover — Cloudflare is the usual choice and is about ten minutes. Without it, your Node process serves every hashed JS chunk and image, spending event-loop time that should be handling requests.

Image optimisation

Three options, in descending order of how much I'd recommend them: use your CDN's image resizing and point the Next.js image loader at it; run the built-in optimiser on your origin, accepting the CPU and configuring a cache; or set `unoptimized: true` and serve pre-sized images. The third is fine for a small site and wasteful of bandwidth on a large one.

The ISR cache

If you use revalidation, configure a cache handler backed by shared storage — Redis or an object store — before you run more than one instance. With the default local-disk cache and several instances, each one regenerates independently and users see content flip between versions depending on where they land. On-demand revalidation reaches only the instance that received the call.

Test ISR by revalidating a page and then reloading fifteen times in a row. If the content alternates, your cache is per-instance. This takes a minute to check and is very unpleasant to discover from a customer report.

Step 5: cut over with a rollback that works

  1. Lower the DNS TTL on your domain to 60 seconds, at least 24 hours before you plan to move. Do this first — it's the step that makes a fast rollback possible, and it only works with notice.
  2. Send a small share of traffic to the new origin, either through your CDN's load balancing or a weighted DNS record. Ten percent is enough to learn something.
  3. Watch p99 latency and error rate, not averages. Averages hide exactly the problems a new platform introduces.
  4. Leave it for a full week including a weekend. Weekly traffic patterns surface cache behaviour and scheduled-job interactions that a Tuesday afternoon won't.
  5. Shift to 100%, then leave the Vercel deployment live and able to serve for two more weeks. Not paused — actually able to serve.

Step 6: the things you now own

  • Preview environments per pull request. Most platforms have an equivalent; wire it up before your team notices it's gone, because it's the feature they'll miss first.
  • Rollback. Confirm what rolling back means on the new platform — a blue-green flip to the previous deployment is what you want, and it should be one command.
  • Uptime monitoring and alerting. Vercel was quietly telling you when deploys failed.
  • TLS certificates, unless your CDN terminates them for you. It probably does; confirm rather than assume.

What this actually costs you

Realistically: an afternoon to get the standalone build running, a day for CDN and image handling, a day for the ISR cache if you use revalidation, and a week of parallel running you mostly spend waiting. The failure modes are concentrated in three places — the missing static-asset copy, environment variables that needed to exist at build time, and per-instance ISR — and if you handle those three deliberately, the rest is unremarkable.

Frequently asked questions

Why is my self-hosted Next.js site missing CSS and images?

The standalone build deliberately excludes the public directory and .next/static, because on Vercel those are served by the CDN rather than by the Node server. When you run the standalone output anywhere else, nothing serves them and the page renders unstyled with broken images. The fix is two copy commands in your build step, placing public/ and .next/static/ next to the server file inside the standalone directory. It looks like a catastrophic failure and is one of the smallest problems in the whole migration — but it is also the first thing you will hit, which is why it is worth knowing before you start.

How do I handle Next.js image optimisation off Vercel?

Three options. The best is to use your CDN's image resizing and point the Next.js image loader at it, which keeps the work off your origin and gives you edge caching for free. The second is to let the built-in optimiser run on your server, which works but consumes CPU on the same process serving requests and needs a cache configured so it does not re-optimise the same image endlessly. The third is setting unoptimized: true and shipping pre-sized images from your build, which is entirely reasonable for a small site and wasteful of bandwidth once you have real traffic and many image variants.

Does ISR work outside Vercel?

Yes, with a caveat that matters as soon as you run more than one instance. By default the incremental static regeneration cache is written to local disk, so each instance regenerates pages independently, holds its own version, and on-demand revalidation only reaches whichever instance received the call. Users then see content flip between versions depending on which instance serves them. The fix is a custom cache handler configured in next.config and backed by shared storage such as Redis or an object store. Test it by revalidating a page and reloading fifteen times — alternating content is the symptom and it shows up immediately.

How do I roll back if the migration goes wrong?

Set it up before you need it. Lower your DNS TTL to around 60 seconds at least a day before cutover, because a TTL change only helps with notice — resolvers hold the old value for however long the previous record told them to. Then shift traffic gradually rather than all at once, using your CDN's load balancing or weighted DNS, so a rollback is a weight change rather than a propagation wait. Keep the old deployment genuinely able to serve for a couple of weeks after you reach full traffic, not merely paused, and confirm the new platform's own rollback is a single command that flips to the previous deployment.

How long does a Vercel migration take?

For a typical Next.js app, budget about a week of elapsed time and two to three days of actual work. Getting the standalone build running locally is an afternoon. CDN setup and image handling is roughly a day. Configuring a shared ISR cache is another day if you use revalidation. The rest is parallel running, which is mostly waiting and watching. Nearly all of the risk concentrates in three failure modes: the missing static-asset copy, environment variables that needed to exist at build time rather than runtime, and per-instance ISR caching. Handle those deliberately and the remainder is uneventful.

Keep reading

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.