DEV Community

Cover image for Self-Hosting Next.js in 2026: The Complete VPS Guide for Teams Leaving Vercel
Deploynix
Deploynix

Posted on Originally published at deploynix.io

Self-Hosting Next.js in 2026: The Complete VPS Guide for Teams Leaving Vercel

Every team that leaves Vercel can point to the exact invoice that did it. For months the bill sits at $20 per seat and nobody thinks about it. Then a launch goes well, image bandwidth spikes, function invocations climb, and the invoice jumps from $150 to $900 in a single billing cycle. The app didn't change. The pricing model did what pricing models do.

We've watched this play out repeatedly with teams migrating to our platform, and the pattern is consistent. The trigger is rarely a single outrageous bill. It's the realization that costs now scale with success in a way the team can't predict or cap. Meanwhile, the thing they're paying for, a Node.js server behind a CDN, has never been cheaper to run yourself. A $6-14/month VPS in 2026 can comfortably host a dozen containerized apps, and open-source tooling has largely closed the deployment-UX gap that justified managed platforms in the first place (DigitalOcean's Vercel-alternatives roundup).

This guide is the complete migration path: when leaving makes sense (and when it doesn't), what you must replace item by item, the standalone build, the nginx layer, ISR caching on a single box, atomic deploys with rollback, and the scaling path beyond one server. It's written by people who run this stack in production, not from a pricing page.

Key Takeaways- Self-hosting Next.js makes sense for steady-traffic, bandwidth-heavy, or compliance-bound apps; less so for edge-function-heavy stacks. - output: 'standalone' produces a self-contained server.js you run under a process manager behind nginx. - Vercel Enterprise contracts run a median of roughly $45,000/year (makerkit); a capable VPS costs under $200/year at the time of writing. - ISR cache persistence and image optimization are the two details most migrations get wrong.

When Does Leaving Vercel Make Sense?

The honest answer: when your costs have become predictable in the wrong direction. If you can look at your traffic graph and your Vercel invoice and draw a straight line between them, self-hosting converts a variable cost into a fixed one. Vercel Enterprise contracts run a median of roughly $45,000 per year, which is exactly why regulated and cost-sensitive teams keep landing on self-hosting (makerkit's Next.js hosting guide).

Four situations make the move clearly worthwhile:

Steady, predictable traffic. Vercel's elasticity is valuable when your traffic is spiky and unpredictable. If your app serves roughly the same audience every day, you're paying an elasticity premium for elasticity you don't use. A fixed-size VPS handles fixed-size traffic at a fixed price.

Bandwidth-heavy workloads. Image galleries, video-adjacent apps, file downloads, anything where egress dominates. Platform bandwidth pricing is where invoices go vertical. On a VPS, providers like Hetzner include 20 TB of traffic on plans that cost less than a lunch, at the time of writing.

Data residency and compliance. If your contracts or regulators require data to live in a specific country, or you need to answer "where exactly does this run?" with a server you control, a VPS in the right region is the simple answer. We covered the same dynamic for backend teams in our piece on cloud repatriation from AWS to a $10 VPS, and the logic transfers directly to frontend hosting.

Cost ceilings. Some teams simply need a number that cannot surprise them. A VPS bill is that number.

When It Doesn't Make Sense

Be equally honest about the other side. If your app leans heavily on Vercel-specific edge primitives, dozens of edge functions, edge middleware doing per-request geo logic, edge config, you'll spend real engineering time rebuilding those behaviors, and the result will run in one region instead of hundreds. That trade can be a net loss.

And if you're a two-person team with zero appetite for operations, think hard. Modern tooling has made self-hosting dramatically easier, but "easier" is not "zero." Someone still owns the server. If nobody on the team wants that pager, even in its mild modern form, the Vercel premium is buying something real: the ability to never think about infrastructure. That's a legitimate purchase.

What Are You Actually Replacing?

Vercel is not magic. It's a well-integrated bundle of components, each of which has a self-hosted equivalent. Here's the honest mapping:

Vercel feature

Self-hosted equivalent

Effort

Serverless/edge functions

Node.js server routes behind nginx (API routes run in the same process)

Low for API routes, high for true edge logic

ISR / on-demand revalidation

Next.js file-system cache (persist .next/cache across releases) or a custom cache handler backed by Redis

Medium

Image optimization

next/image with sharp installed on the server, or offload to Cloudflare

Low

Preview deployments

Staging sites per branch (a second site on the same or another server)

Medium

Global edge network / CDN

Cloudflare (or another CDN) in front of your origin

Low

Automatic SSL

Let's Encrypt via certbot, or your platform handles it

Low

Analytics / Speed Insights

Self-hosted Plausible, Umami, or Sentry for performance

Low

Zero-config Git deploys

A deploy script or a platform like Deploynix pulling from Git

Low to medium

Two rows deserve emphasis because they're where migrations get sloppy: ISR cache persistence and image optimization. We'll spend full sections on both.

What You Lose, With No Sugarcoating

The one thing you cannot replicate on a single VPS is Vercel's global edge network. Your server lives in one region. A user in Sydney hitting your Frankfurt VPS pays a real latency tax on the first byte of every server-rendered page.

The standard mitigation is Cloudflare in front of your origin. Static assets, images, and cached HTML get served from Cloudflare's POPs worldwide, which recovers most of the perceived performance for most apps. Server-rendered, uncacheable pages still make the round trip to your origin. For a typical SaaS dashboard where users are concentrated in one or two regions, this is a non-issue. For a globally distributed content site with heavy SSR, it's a genuine trade-off you should measure before committing. We wrote up the full setup in using Cloudflare with Deploynix for CDN, DDoS protection, and SSL.

What Does It Actually Cost?

Numbers below are at the time of writing (August 2026) and assume a production app doing a few hundred thousand requests per month with moderate image traffic.

Item

Vercel Pro

Self-hosted VPS

Base platform

$20/seat/month

$0

Compute

Included, then usage-billed

$12-24/month (2-4 vCPU VPS)

Bandwidth

1 TB included, ~$0.15/GB after

1-20 TB included with the VPS

Image optimization

Usage-billed after included quota

$0 (sharp) or $0 (Cloudflare free tier)

SSL

Included

$0 (Let's Encrypt)

Preview environments

Included

$0-12/month (staging site, optionally a second small VPS)

Analytics

Usage-billed add-on

$0-9/month (self-hosted Plausible/Umami)

Typical monthly total (5-person team)

$100-400+, usage-dependent

$15-40, fixed

The absolute numbers matter less than the shape. The Vercel column scales with usage and team size. The VPS column is flat until you deliberately resize it. For teams doing serious traffic, the gap widens: the median Vercel Enterprise contract of ~$45,000/year buys roughly two decades of a well-specced dedicated server.

What the table doesn't show is your time. Budget a day or two for the initial migration and an hour or two per month for maintenance (mostly unattended if you automate updates). If your loaded engineering cost makes those hours worth more than the savings, that's your answer, and it's a fine one.

How Do You Build Next.js for a VPS?

Next.js ships a build mode designed exactly for this: standalone output. Instead of requiring your full node_modules tree (often 500 MB+) on the production server, standalone mode traces exactly which files your server code actually imports and copies only those into .next/standalone, alongside a self-contained server.js.

Enable it in next.config.js:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
};

module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

After next build, the .next/standalone directory contains everything needed to run the app: a minimal node_modules, your compiled server code, and server.js as the entry point. Two directories are deliberately excluded and must be placed manually: public/ and .next/static/. That exclusion is intentional, because the docs assume you'll serve those through a CDN or web server. We will, via nginx, in the next section.

Running the server is one command:

HOSTNAME=127.0.0.1 PORT=3000 NODE_ENV=production node server.js
Enter fullscreen mode Exit fullscreen mode

Bind to 127.0.0.1, not 0.0.0.0. Nothing should reach the Node process except nginx on the same machine.

Pick a Node LTS and Pin It

Run the current LTS (Node 22 at the time of writing) and pin the major version in package.json engines and in your server provisioning. The failure mode you're avoiding: your CI builds on Node 22, your server runs Node 20, and you debug a native-module mismatch at 11 pm. Same major version everywhere, always.

The NEXT_PUBLIC_ Pitfall

This one bites almost every first migration, so read it twice. Next.js has two classes of environment variables:

  • Server-side variables (DATABASE_URL, API_SECRET) are read at runtime. Change them, restart the process, done.
  • NEXT_PUBLIC_* variables are inlined into the JavaScript bundle at build time. They are string-replaced into your client code during next build. Changing them on the server after the build does nothing.

On Vercel this distinction is invisible because every env change triggers a rebuild. On your VPS, it means NEXT_PUBLIC_ values must be present in the environment where next build runs, and changing one requires a new build, not a restart. Structure your deploy script accordingly, and resist the temptation to put anything in NEXT_PUBLIC_ that isn't truly public. It ships to every browser.

How Do You Keep the Process Running?

node server.js in a tmux session is not a production strategy. You need a process manager that starts the app on boot, restarts it on crash, and captures logs. Systemd works; PM2 works; we'll show Supervisor because its config is the most readable:

[program:nextjs-myapp]
command=/usr/bin/node server.js
directory=/var/www/myapp/current/.next/standalone
environment=NODE_ENV="production",HOSTNAME="127.0.0.1",PORT="3000"
user=deploy
autostart=true
autorestart=true
startretries=5
stopwaitsecs=10
stdout_logfile=/var/log/myapp/next.out.log
stderr_logfile=/var/log/myapp/next.err.log
Enter fullscreen mode Exit fullscreen mode

Note the directory points at current/, a symlink we'll create in the deploy section. That symlink is the hinge that makes atomic releases work. Also note stopwaitsecs=10: Next.js handles SIGTERM gracefully, finishing in-flight requests before exiting, and you want to give it those seconds rather than SIGKILL-ing mid-request.

Runtime environment variables can live in the Supervisor stanza for simple setups, or in an env file loaded by a wrapper script for anything with secrets. Either way, they're runtime-only. The NEXT_PUBLIC_ values were already baked in at build time.

The nginx Layer: Where the Performance Lives

nginx sits in front of the Node process and does three jobs: TLS termination, reverse proxying dynamic requests, and serving static assets directly so Node never sees them. That last job matters more than people expect. Every request nginx answers from disk is a request your Node event loop doesn't handle.

Here's a production-ready server block:

server {
    listen 443 ssl http2;
    server_name myapp.com;

    ssl_certificate     /etc/letsencrypt/live/myapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;

    client_max_body_size 25m;

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
    gzip_min_length 1024;

    # Immutable build assets: serve from disk, cache for a year
    location /_next/static/ {
        alias /var/www/myapp/current/.next/static/;
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    location /public/ {
        alias /var/www/myapp/current/public/;
        expires 7d;
        access_log off;
    }

    # Everything else goes to the Node process
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }
}
Enter fullscreen mode Exit fullscreen mode

A few deliberate choices worth explaining:

The **/_next/static/ location block** is the payoff for standalone mode's "bring your own static serving" design. Next.js fingerprints every file in that directory with a content hash, so the files are immutable by construction. max-age=31536000, immutable tells browsers to never revalidate them. Combined with Cloudflare in front, these assets get served from edge POPs and your origin barely sees them.

The Upgrade headers (proxy_http_version 1.1 plus the Upgrade/Connection pair) enable WebSocket passthrough. You need this for Next.js HMR if you ever proxy a dev server, and for any production WebSocket usage in the app itself. Leaving them out produces the classic symptom of connections that open and instantly die.

client_max_body_size 25m because nginx's 1 MB default will 413 your first file-upload feature and you'll spend an hour blaming Next.js.

If you want to go deeper on tuning, our guide to nginx configs that actually matter is Laravel-flavored but 80% of it, buffers, gzip levels, keepalive, applies unchanged here.

Enable brotli too if your nginx build includes the module; it compresses JS bundles 15-20% smaller than gzip. If it doesn't, don't recompile nginx just for this. Cloudflare applies brotli at the edge anyway.

How Does ISR Work on a Single Server?

Incremental Static Regeneration is the feature people assume won't survive off Vercel. It survives fine, with one operational detail you must get right.

Self-hosted Next.js implements ISR with a file-system cache in .next/cache. Rendered pages and their revalidation timestamps live there. revalidate intervals, on-demand revalidation via revalidatePath and revalidateTag, all of it works out of the box on a single Node process. No configuration required.

The detail that bites: the cache directory must persist across deploys. If every release ships a fresh .next/cache, you throw away every regenerated page on deploy. The first visitor to each page after a release eats a full render, and if you deploy often, ISR effectively stops existing. Worse, fetch() data cache entries vanish too, so your upstream APIs get hammered right after each deploy, exactly when you least want surprises.

The fix follows the same shared-storage pattern used in Capistrano-style deployments for years: keep the cache outside the release directory and symlink it in.

/var/www/myapp/
├── releases/
│   ├── 20260801121500/
│   └── 20260802093000/
├── shared/
│   └── next-cache/        # persists forever
└── current -> releases/20260802093000
Enter fullscreen mode Exit fullscreen mode

Each release symlinks .next/cache (or specifically .next/cache/fetch-cache plus the ISR page cache, depending on your Next version) to shared/next-cache. Deploys swap the current symlink; the cache never moves. One caveat: after a build that changes a page's structure, Next's build ID changes and stale entries are ignored safely, so you don't need to invalidate manually.

If you later scale to multiple Node instances, the file-system cache stops being shared state, and you'll want Next's custom cacheHandler config pointed at Redis. On one server, the file system is simpler and fast. Don't add Redis before you need it.

What About Image Optimization?

next/image on Vercel silently uses their image optimization service, and it's a top-three source of surprise billing. Self-hosted, you have two good options.

Option one: optimize on the server. Install sharp as a production dependency (npm install sharp) and Next.js uses it automatically for on-the-fly resizing and WebP/AVIF conversion. Sharp is fast native code; a 2-vCPU VPS handles typical image workloads without drama. Optimized images land in that same .next/cache directory, which is another reason the shared-cache setup above matters.

Option two: offload to the CDN. If you're already putting Cloudflare in front (you should), Cloudflare's image resizing or simply caching your sharp-generated variants at the edge means each unique image size gets rendered on your origin exactly once, then served from POPs forever. For image-heavy sites this combination, sharp for generation, Cloudflare for delivery, gets you 95% of Vercel's image pipeline at a fixed cost.

One warning: configure images.remotePatterns in next.config.js strictly. An open image optimizer is a free resizing service for the entire internet, and people will find it.

Deploys: Atomic Releases, Zero Downtime, Rollback

This is where "run node on a VPS" becomes an actual production workflow. The requirements are simple to state: a deploy either fully succeeds or fully doesn't, users never see a half-deployed app, and rollback takes seconds.

The releases/current-symlink structure from the ISR section gives us all three. Here's a complete deploy script:

#!/usr/bin/env bash
set -euo pipefail

APP_DIR=/var/www/myapp
RELEASE=$(date +%Y%m%d%H%M%S)
RELEASE_DIR=$APP_DIR/releases/$RELEASE

# 1. Fetch code
git clone --depth 1 git@github.com:acme/myapp.git "$RELEASE_DIR"
cd "$RELEASE_DIR"

# 2. Build-time env (NEXT_PUBLIC_ vars must exist NOW, not later)
ln -sf $APP_DIR/shared/.env.build .env.production

# 3. Clean, reproducible install and build
npm ci
npx next build

# 4. Assemble the standalone runtime
cp -r public .next/standalone/public
cp -r .next/static .next/standalone/.next/static

# 5. Link the persistent ISR/image cache into the new release
rm -rf .next/standalone/.next/cache
ln -s $APP_DIR/shared/next-cache .next/standalone/.next/cache

# 6. Atomic switch: this line is the deploy
ln -sfn "$RELEASE_DIR" $APP_DIR/current

# 7. Graceful restart under the process manager
supervisorctl restart nextjs-myapp

# 8. Keep the last 5 releases for instant rollback
cd $APP_DIR/releases && ls -1t | tail -n +6 | xargs -r rm -rf
Enter fullscreen mode Exit fullscreen mode

The atomicity lives in step 6. ln -sfn replaces the symlink in a single filesystem operation; there is no moment where current points at a half-built release. If the build fails at step 3, the script exits (set -e) and production never noticed a deploy was attempted.

Rollback is the same operation pointed backwards: ln -sfn to the previous release directory, restart, done. Under ten seconds, no rebuild.

The restart in step 7 causes a brief blip on a bare single process: Supervisor stops the old process gracefully, then starts the new one, so there's a window of a second or two where nginx gets connection refused. For most apps that's acceptable (nginx can even be configured to retry). For true zero-downtime you start the new process on an alternate port, health-check it, flip nginx's upstream, then drain the old one. That's the dance a deployment platform automates for you, and it's tedious enough by hand that it's usually the point where teams stop enjoying the DIY approach.

Preview Deploys Become Staging Sites

Vercel's per-PR preview URLs are genuinely excellent, and the self-hosted equivalent is more manual: a staging site. Same server (or a cheap second one), same deploy script pointed at a staging branch, staging.myapp.com in nginx, HTTP basic auth in front if the content is sensitive. You won't get a URL per pull request without building tooling for it. Most teams find one persistent staging environment plus local dev covers 90% of what they actually used previews for. Be honest with yourself about whether your team is in the other 10%.

Vercel vs. Self-Hosted: The Fair Comparison

Vercel

Strengths: The developer experience is the best in the industry, and that's not faint praise. Push to a branch, get a preview URL, merge, it's live globally in two minutes. The edge network is real infrastructure with real POPs, not marketing. Framework integration is perfect by definition, since the same company builds Next.js. Zero operational burden, ever.

Best for: Small teams with no ops appetite, spiky or unpredictable traffic, apps that genuinely exploit edge functions and middleware, agencies shipping many small sites, and anyone whose engineering time is worth far more than the hosting delta.

Considerations: Costs scale with usage and success, and the ceiling is high: Enterprise medians around $45K/year at the time of writing. Bandwidth and image optimization are the classic bill inflators. You're also accepting vendor coupling; features like edge config and their KV don't travel with you.

Self-hosted VPS

Strengths: Fixed, predictable cost that's often 5-10x lower at moderate scale. Full control: your nginx, your Node flags, your data location, your log retention. No usage anxiety. The stack (nginx + Node + a process manager) is boring, documented, twenty-years-mature technology that any Linux-literate engineer can debug at 2 am.

Best for: Steady traffic, bandwidth-heavy apps, data-residency requirements, cost-capped teams, and anyone already running a VPS for their backend who can co-locate the frontend. If you're pairing a Next.js frontend with a Laravel API, our walkthrough on deploying a Laravel API with a separate Next.js or Nuxt frontend covers that exact topology.

Considerations: You own the server: updates, monitoring, capacity. Single-region origin latency for far-away users (mitigated but not eliminated by Cloudflare). Preview environments are staging sites, not per-PR URLs. Edge-function-heavy apps need rearchitecting. The first migration costs a day or two of real engineering time.

Monitoring and the Scaling Path

Self-hosting without monitoring isn't self-hosting, it's gambling. The minimum viable setup: uptime checks on the public URL, process-level checks (is node server.js running, is memory stable), and disk/CPU/RAM alerts on the VPS itself. Next.js apps have one specific failure mode worth watching: slow memory growth from unbounded in-memory caching or leaky third-party SDKs. A memory graph with an alert at 85% catches it days before the OOM killer does.

The scaling path is refreshingly boring:

  1. Vertical first. A standalone Next.js server is efficient; a 4-vCPU/8 GB VPS serves a surprising amount of SSR traffic. Resizing a VPS is a five-minute reboot. Our load-testing writeup on how much traffic a $5 server can actually handle used Laravel as the subject, but the headline finding, that small servers are wildly underestimated, applies at least as strongly to Node.
  2. Cloudflare aggressively. Before adding servers, make sure cacheable responses are actually being cached at the edge. Many "we need to scale" moments are really "we're rendering the same page 10,000 times" moments.
  3. Horizontal when vertical runs out. A load balancer in front of two or more Node instances. Here the ISR caveat returns: the file-system cache is per-machine, so two instances will regenerate pages independently and can serve briefly inconsistent versions. At this point, wire up Next's cacheHandler to Redis so all instances share one cache. This is the single biggest architectural difference between one server and several, and knowing it in advance makes the transition a config change instead of an incident.

Running Next.js on Deploynix

Everything above is exactly what Deploynix automates, so here's the concrete version of this guide on our platform. Next.js is one of 14 first-class project types (alongside Nuxt and SvelteKit for SSR), not a generic "Node app" you configure from scratch. When you create a Next.js site, it runs as a managed Node process with a sensible default start command and port, behind an nginx reverse proxy configured the way this article describes, static asset serving and proxy headers included.

Deploys pull from GitHub, GitLab, or Bitbucket and use the atomic release structure from the deploy section: zero-downtime switches, one-click rollback to a previous release, shared directories for cache persistence. SSL is free and automatic, monitoring and alerts are built in, and daemons and queue workers are managed the same way the Next process is, so a background job runner sits next to your app without hand-writing Supervisor stanzas. You bring the VPS from DigitalOcean, Vultr, Linode, Hetzner, AWS, or any custom server with root access; we provision it. When you outgrow one box, a load balancer is its own server type.

We built it because we got tired of maintaining the bash script above across a dozen servers. If you're coming from a managed platform, the mental model transfer is small; teams arriving from Railway, Render, or Fly.io will find the moving parts familiar, and we wrote a dedicated guide on migrating from Railway, Render, or Fly.io to a VPS covering that path.

FAQ

Do I lose ISR and on-demand revalidation by leaving Vercel?

No. ISR works out of the box in self-hosted Next.js via a file-system cache, including revalidatePath and revalidateTag. The one requirement is persisting .next/cache across deploys with a shared directory. On multiple servers, switch to a Redis-backed custom cache handler so instances share one cache.

What VPS size should I start with for a production Next.js app?

At the time of writing, a 2-vCPU/4 GB VPS ($12-24/month) comfortably runs a typical SSR app doing hundreds of thousands of requests per month, especially with Cloudflare caching in front. Start there, watch memory and CPU for two weeks, and resize vertically before considering anything more complex.

Can I keep using next/image without Vercel?

Yes. Install sharp as a production dependency and Next.js optimizes images on your server automatically. Put Cloudflare in front so each generated variant is cached at the edge and rendered only once. Lock down images.remotePatterns so strangers can't use your optimizer as a free service.

How do I replicate preview deployments?

The practical self-hosted equivalent is a persistent staging site: same deploy pipeline, staging branch, its own subdomain, basic auth if needed. Per-PR preview URLs are possible but require custom tooling. Most teams find one staging environment covers the real use case, which is "let a non-developer click through the change before merge."

Is a single-region VPS actually fast enough for global users?

For static assets and cacheable pages behind Cloudflare, yes, they're served from edge POPs regardless of where your origin lives. Uncached SSR requests pay the round trip to your region, typically 80-250 ms extra for far-away users. If most of your audience is in one or two regions, it's rarely noticeable. Measure with real-user monitoring before assuming either way.

Where to Go From Here

The short version of 3,000 words: leaving Vercel in 2026 is a well-trodden path, not a leap. The build (output: 'standalone'), the runtime (a process manager), the front door (nginx plus Cloudflare), and the deploys (atomic releases with a persistent cache) are all solved problems with boring, reliable answers. What you give up is a world-class DX and a global edge; what you get back is control and a hosting bill that never surprises you again. Whether that trade is right depends on your traffic shape and your team's ops appetite, and now you have the full picture to decide.

Your next step: run the numbers. Pull your last three Vercel invoices, put them next to a $24/month VPS, and price your own migration day honestly. If the math says move, spin up a test server, follow the build and nginx sections above against a staging domain, and see the whole stack working before you touch production DNS. The first deploy takes an afternoon. Every one after that takes a git push.

Top comments (0)