Moving a Next.js app from Vercel to a VPS: what we measured and what broke
Tutorial · · 8 min read
Vercel is still the fastest way to put a Next.js app online. The question comes later, when the bill grows with every developer you add and with usage you can't predict. The usual answer is "move it to a VPS", but few people tell you what that actually looks like.
So we tried it. We took a Next.js 16.4 app as create-next-app generates it and added the pieces that usually worry people: route handlers, proxy.ts (the old middleware), next/image and environment variables. Then we deployed it from GitHub to a test server with 2 vCPUs, using SecuryBlack, which is our product. Below are the numbers, the two things that broke and how we fixed them.
What it costs, October 2026
Vercel Pro costs $20 a month per developer and includes $20 of usage credit. Since September 2026, new Pro teams get the Flat Rate CDN: 1 million requests and 1 TB of transfer included, and above that tiers from $20 a month (10 million requests) to $300 (150 million). Functions are billed by active CPU (from $0.128 per hour) and invocations (from $0.60 per million) once you go past what's included.
A VPS costs the same every month. Hetzner raised its prices twice in 2026. In Germany and Finland, excluding VAT:
| Server | vCPU | RAM | Price/month | Traffic |
|---|---|---|---|---|
| CX23 (Cost-Optimized) | 2 | 4 GB | €5.99 | 20 TB |
| CPX12 (Regular Performance) | 1 | 2 GB | €11.99 | 20 TB |
| CPX22 (Regular Performance) | 2 | 4 GB | €19.99 | 20 TB |
The CX23 is the sweet spot, but when we checked it was listed as out of stock. Other providers sit in similar ranges.
SecuryBlack is free during the beta. After that, the Hobby plan stays free for one server and Pro costs €12 a month. If you sign up during the beta, Pro stays at €6 a month for life.
Be honest with yourself here. If you work alone and your traffic is modest, Vercel Pro at $20 a month is a fair price and you may not save much. Moving pays off when you have several developers, heavy server-side work (function CPU) or a database you're already paying for elsewhere: on the VPS it runs on the same machine.
Step 1: add a healthcheck route
Before switching traffic to a new version, SecuryBlack checks that it responds. Give it a cheap route that doesn't touch the database:
// app/api/health/route.ts
export function GET() {
return Response.json({ ok: true });
}
Step 2: connect the server and GitHub
Create a VPS with Ubuntu or Debian, add it in the panel and run the install command it gives you. Then connect GitHub and choose which repositories SecuryBlack can read.
Step 3: create the app
In Apps → New app choose the repository and branch, port 3000 and the health check path /api/health. Leave the build on Automatic: with no Dockerfile in the repository, Railpack detects Next.js and builds the image.
Step 4: environment variables
Here's the first trap (more below): turn on Build for any variable that starts with NEXT_PUBLIC_. Secrets like database URLs or API keys stay as runtime-only variables.
Step 5: domain and HTTPS
Create an A record pointing your domain at the server's IP and add the domain to the app. The certificate comes from Let's Encrypt and renews by itself. You can add more domains and redirect www.
Step 6: push
From here, every push to the branch deploys again. The GitHub webhook started our build about one second after the push.
The numbers
| Measured | |
|---|---|
| First deploy, build included | 110 s |
| Each later push (build cache) | 59 s |
| Build on 2 vCPUs | 106 s, 57 s with cache |
| RAM used by the running app | 74 MB |
| Extra RAM during the build | about 0.4 GB |
| Image size (Railpack) | 1.23 GB |
The switch has no downtime: the old version keeps serving until the new one passes the healthcheck, and then it's removed. The build RAM figure comes from one-minute samples, so the real peak may be a little higher. With 2 GB of RAM you have room for a small app. We didn't try 1 GB.
What broke
1. NEXT_PUBLIC_ variables were empty in the browser
Next.js copies NEXT_PUBLIC_* variables into the JavaScript bundle when it builds, not when the app starts. If you only define them at runtime, the browser gets undefined.
Fix: turn on Build for those variables. On Vercel you never notice, because every variable is available at build time and at runtime.
2. A route handler returned null for a secret
This route read a runtime secret and always answered hasSecret: false:
export function GET() {
return Response.json({ hasSecret: Boolean(process.env.SECRET_GREETING) });
}
In Next.js 16, a GET route handler that doesn't read anything from the request is prerendered during the build. SecuryBlack doesn't pass runtime variables to the build, so secrets never end up inside the image, and the route was saved with the secret missing.
Fix: tell Next.js to render it per request:
import { connection } from "next/server";
export async function GET() {
// Render at request time, so runtime env vars are read on the server.
await connection();
return Response.json({ hasSecret: Boolean(process.env.SECRET_GREETING) });
}
Server components that read secrets have the same issue and the same fix.
What worked without changes
proxy.ts: our response header arrived on every page.next/image: images were resized and served as WebP. On a VPS your server does that work and keeps the results in its cache.- App Router and server components: nothing to change.
What you give up by leaving Vercel
- The global CDN. Your app is served from wherever your server is. If your visitors are spread around the world, put Cloudflare in front to cache static files.
- Preview deployments per pull request. SecuryBlack doesn't have them yet.
- Automatic scaling. A VPS has fixed capacity. Watch CPU and RAM, and move to a bigger server when you need to.
- Vercel Analytics. You can install Umami or Plausible from a template, on the same server.
What you take on (and what we cover)
With your own server, its security and its data are your job. That's why we built SecuryBlack on top of the deploys:
- a security audit of SSH, the firewall, ports and updates, with the exact command for each fix;
- encrypted backups of PostgreSQL and folders, with restore and an alert if one stops running;
- uptime monitoring and alerts by email, Telegram or webhook.
Conclusion
Moving a Next.js app from Vercel to a VPS took us one afternoon. Most of it was understanding two details: NEXT_PUBLIC_ variables are fixed at build time, and routes that read secrets must render per request. In return you get a fixed bill and your app, its database and its data on a server you control.
Deploy your first app: it's free during the beta, and Pro stays at €6 a month for life if you sign up now.