Musa Yılmaz
·10 min read

Self-Hosting Next.js: What Changes When You Leave Vercel

nextjsself-hostingdevopsdeployment

Moving a site off Vercel while everything still works there looks like busywork. Three things usually force the decision anyway: the Hobby plan does not allow commercial use, the work does not fit inside serverless function limits, or the data has to live on a machine you control. I covered the real limits of Vercel's free tier with actual numbers in a separate post. This one answers the next question.

The short answer is that less changes than you expect. The long answer is a handful of details that break quietly, and those details are the whole point.

The minimum requirement is one Node.js process

The Next.js deployment guide is unusually blunt about this: to run Next.js, your platform needs a Node.js server. That is it. A single next start process handles every feature correctly, including Server Components, ISR, PPR, Cache Components, Server Actions, Proxy and after().

So "you lose ISR if you leave Vercel" is simply wrong. You do not lose features. You lose the party that was building the infrastructure underneath them. CDN caching, edge compute and shared cache are about performance and multi-instance consistency, not correctness.

What each feature actually needs

The feature matrix in the docs makes it clear what comes free on a single box:

FeatureStreamingShared cache
Server ComponentsRequiredNo
ISR (time-based)NoRecommended
ISR (on-demand)NoRecommended
Partial PrerenderingRequiredRecommended
Cache ComponentsRequiredRecommended
Proxy / MiddlewareNoNo
Server ActionsRequiredNo
after()NoNo

The "shared cache recommended" rows only matter if you run more than one instance. On a single machine you can ignore that entire column. The "streaming required" rows apply everywhere, and a misconfigured reverse proxy is the first thing that breaks them.

Put nginx in front, then turn buffering off

The docs recommend never exposing the Next.js server directly to the internet. A reverse proxy absorbs malformed requests, slow-connection attacks, payload size limits and rate limiting, so the render server can spend its resources rendering. I built that whole arrangement in a production Docker Compose setup for Next.js and Django.

Here is the catch: nginx buffers upstream responses by default, and a buffered response is not a streamed one. The page still renders. It just arrives in one piece instead of progressively, the Suspense fallbacks never reach the user, and time to first byte stretches out to match the full render time. Nothing errors. You only lose the speed, and you will not find out why.

Next.js suggests solving it with a response header:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*{/}?',
        headers: [
          {
            key: 'X-Accel-Buffering',
            value: 'no',
          },
        ],
      },
    ]
  },
}

The whole chain has to cooperate. If there is a load balancer in the path, it must support chunked transfer encoding or HTTP/2 streaming and must not buffer the response before passing it on.

The ISR cache now lives on your disk

On Vercel you never think about where the ISR cache is. On your own server you have to.

By default Next.js keeps generated pages and revalidated content on the local filesystem of each server instance, plus an in-memory layer capped at 50 MB. For a single next start process with a persistent disk this works with no configuration at all.

It breaks in two situations.

Ephemeral containers. If the filesystem resets on every deploy, so does the cache. That is not a correctness bug, but the first visitors after each deploy pay the cold render cost. Mounting the cache directory on a persistent volume fixes it.

More than one instance. Each pod keeps its own cache. A revalidateTag() call only clears the instance that received it; the others keep serving stale content. The fix is a custom cache handler backed by shared storage:

// next.config.js
module.exports = {
  cacheHandler: require.resolve('./cache-handler.js'),
  cacheMaxMemorySize: 0, // disable the default in-memory cache
}

For tag invalidation to propagate between instances you also need to implement refreshTags() in that handler. It runs before each request and syncs tag state from shared storage. The revalidatePath flow I described in how Next.js ISR and on-demand revalidation work behaves identically on one server and needs this extra plumbing on several.

My practical advice: if you are not genuinely scaling horizontally, do not build this layer at all. One process with a persistent disk is far less maintenance than a Redis-backed cache handler.

There is a build-side cache too. Next.js keeps a cache shared between builds in .next/cache. If your CI does not persist that directory, every build starts from scratch and takes longer than it needs to.

If you put a CDN in front, do not touch the rsc header

This is where self-hosters most often trip.

Next.js emits standard Cache-Control headers per route type: a one-year s-maxage for fully static pages, s-maxage plus stale-while-revalidate for ISR pages, and a no-store header for dynamic ones. Any CDN that respects those directives can cache static and ISR pages at the edge.

Two things to know before you rely on that.

On-demand revalidation does not reach the CDN. revalidateTag() and revalidatePath() invalidate the Next.js server cache. The CDN keeps serving its own copy until s-maxage expires. To update content in seconds you have to call your CDN's purge API alongside the revalidation, for both the HTML and the RSC variant of the affected keys.

The rsc header has to reach the server. It tells the server to return a React Server Components payload instead of HTML. If a CDN strips it, the client router asks for RSC data and gets HTML back, which breaks client-side navigation and turns every link into a full browser navigation.

For the same reason the _rsc search parameter must be part of the cache key. It is the discriminator that separates HTML responses from RSC ones and distinguishes prefetch variants. Some CDNs drop query parameters from cache keys by default, and that setting has to go.

This is a class of bug you never meet on Vercel, and the symptom misleads you: the site loads, pages render, navigation is just inexplicably slow.

Image optimization and sharp

Image Optimization through next/image works with zero configuration when you self-host with next start. The one extra dependency Next.js needs is the sharp package.

Two details worth knowing.

If you use output: "standalone", and you should because it shrinks the Docker image dramatically, make sure native binaries end up in the traced output:

// next.config.js
module.exports = {
  outputFileTracingIncludes: {
    '/*': ['node_modules/sharp/**/*'],
  },
}

On glibc-based Linux systems, sharp can consume excessive memory without additional allocator configuration. On a small VPS that is often the answer to "why did memory suddenly spike".

Environment variables are baked into the build

On Vercel you change a variable in the dashboard and redeploy. The logic is identical on your own server, but the consequence bites harder: variables prefixed with NEXT_PUBLIC_ are inlined into the JavaScript bundle during next build. You cannot promote the same image to staging and production with different values. Each environment needs its own build.

If you want one image across environments, read the value on the server at request time. Calling connection() opts the component into dynamic rendering explicitly:

import { connection } from 'next/server'
 
export default async function Component() {
  await connection()
  const value = process.env.MY_VALUE
  // ...
}

If you scale past one server

Skip this section if you stay on a single machine. Once you run two or more instances behind a load balancer, three settings stop being optional.

Server Actions encryption key. Next.js encrypts Server Function closure variables before sending them to the client, and by default generates a fresh key per build. Two instances with different keys cannot decrypt each other's payloads, which surfaces as "Failed to find Server Action". Every instance needs the same key:

NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-generated-key next build

The key must be base64-encoded with a valid AES length: 16, 24 or 32 bytes. Next.js generates 32-byte keys by default.

Version skew. During a rolling deploy some clients request assets from the old build and some from the new one. Missing JavaScript files, unrecognized Server Function IDs and failed navigations all come from this. Setting a deploymentId makes Next.js tag static assets and compare the client's deployment ID with its own, falling back to a hard navigation when they disagree:

// next.config.js
module.exports = {
  deploymentId: process.env.DEPLOYMENT_VERSION,
}

Consistent build ID. If you rebuild per environment, pin the ID with generateBuildId so all containers agree. The latest git hash is the usual choice.

Do not rush the shutdown

Stop the server with SIGINT or SIGTERM, then wait. Next.js finishes in-flight requests and runs any pending after() callbacks before exiting. The docs recommend a drain period of 10 to 30 seconds.

Container setups that skip this silently cut off whatever you moved into after(): transactional email, analytics writes, log shipping. There is no error message. The work just does not happen.

What Vercel was doing for you

You are not losing Next.js features. You are taking over operations:

  • TLS certificates. Issuing and renewing them is your job now. I set that up with Let's Encrypt in deploying Django on AWS Lightsail; the approach is the same for a Next.js server.
  • Global distribution. One server in one region is slower for distant users. A CDN helps, provided you follow the header rules above.
  • Preview deployments. No automatic URL per branch. Build it in CI if you want it.
  • Rollback. There is no "promote the previous deployment" button. Tag and keep your images, and rehearse the way back before you need it.
  • Scheduled work. Cron management moves to system cron or a task queue.
  • Logs and metrics. Server logs pile up on your disk. Without rotation, one day the disk fills.

None of these is hard on its own. Together they are a standing maintenance load that never quite goes away. When you weigh the move, that is what you compare against, not the monthly server bill.

When to move, and when not to

The case for moving is usually clear-cut: a commercial site cannot stay on Hobby, long-running background jobs do not fit under a serverless timeout, data residency rules may dictate where the machine sits, or you already run Django and PostgreSQL on your own server and want everything in one place.

There is only one case against, but it is strong. A personal blog or portfolio does not come close to the free tier's limits. Self-hosting one of those buys you an operations job to save a few dollars a month.

If you are unsure, measure rather than guess. Look at your current usage, see how close to the ceiling you actually are, then decide.

Summary

Next.js runs completely on your own server. One Node.js process is enough. What you take over are the four things Vercel handled behind the scenes: keeping streaming alive through the reverse proxy, knowing where the ISR cache lives, preserving the rsc header and _rsc parameter if you add a CDN, and giving the server a drain period on shutdown.

None of these is mysterious, and none of them happens by itself. All four degrade quietly rather than crash, which makes them much harder to diagnose later than to set up now. Handle them on day one and the rest of the move is uneventful.