Type something to search...
Next.js Self-hosting your application

Next.js Self-hosting your application

Vercel built Next.js, and Vercel's platform is tuned to make every Next.js feature work with zero configuration. That convenience quietly shapes how a lot of tutorials talk about the framework — deploy is presented as a one-command afterthought. But plenty of teams have real reasons to run Next.js somewhere else: existing infrastructure, compliance requirements, cost at scale, or simply not wanting a hard dependency on one vendor. Self-hosting is fully supported, but it moves several responsibilities that a managed platform would otherwise carry for you — caching consistency, streaming support, build coordination across servers — onto your own infrastructure.

None of it is exotic. It's mostly about knowing which of Next.js's "it just works" features quietly assume a single, persistent Node.js process, and adjusting your setup for the ones that don't hold when you're running multiple containers behind a load balancer. This guide walks through everything you need to get right, from a single next start instance up to a fleet of pods coordinating cache state.

Put a reverse proxy in front of it

The first decision, before any Next.js-specific configuration, is architectural: don't expose your Next.js server directly to the internet. Put nginx, Caddy, or your cloud provider's equivalent in front of it.

This isn't Next.js-specific advice so much as general server hygiene, but it's worth stating plainly because a lot of side projects skip it. A reverse proxy absorbs malformed requests, slow-connection attacks (the classic "Slowloris" pattern), oversized payloads, and rate limiting — all before that traffic ever reaches your Node.js process. Node's HTTP server is fast at rendering; it's not particularly hardened against the ways clients on the open internet misbehave. Let something purpose-built for that sit in front of it.

This also happens to be the layer where you'll later configure buffering behavior for streaming (more on that below), so you'll be touching this proxy config again regardless.

Image Optimization works out of the box — mostly

next/image optimizes images at request time with zero setup, as long as you're running next start. The optimization endpoint is just part of the Next.js server process; there's no separate service to stand up.

Two things worth knowing that the docs mention only in passing:

glibc memory usage. If you're self-hosting on a glibc-based Linux distribution (Debian, Ubuntu, most standard Docker base images), the underlying image library (sharp) can balloon memory usage under load unless you tune its memory allocator. This is a genuinely common cause of "why is my Next.js container getting OOM-killed under image-heavy traffic" — worth checking early rather than after it bites you in production.

Static exports need a custom loader. If you're building a fully static export (more on that in the "Usage with CDNs" section), there's no server process to run the optimization endpoint at request time. You'd instead configure a custom image loader pointing at an external service — Cloudinary, imgix, or your own. Images in a static export are optimized by whatever loader you configure, not by Next.js itself, and that optimization happens at runtime against that external service, not at build time.

If none of that appeals to you, you can disable Image Optimization outright with unoptimized and still get every other benefit of next/image — automatic srcset generation, lazy loading, layout-shift prevention — while handling optimization yourself elsewhere.

Proxy: the replacement for what used to be Middleware

If you've used an older version of Next.js, you'll know this file as middleware.js. In this version, that convention has been renamed to Proxy (proxy.js/proxy.ts), and it works self-hosted with zero configuration under next start.

The one hard constraint: Proxy needs access to the incoming request, so it's incompatible with a static export. If you're exporting fully static HTML, there's no running process to intercept a request with.

If you need logic that depends on full Node.js APIs — not just the restricted Edge-style runtime Proxy runs in — you generally have three escape hatches, in rough order of preference:

  1. Move the logic into a Server Component layout. A surprising amount of "middleware-shaped" logic — checking headers, redirecting based on a cookie — can live in a root layout instead, running as a normal Server Component with full Node.js access.
  2. Use next.config.js header/cookie/query matching. Both redirects() and rewrites() support matching on headers, cookies, and query parameters directly in config, without needing request-time code at all.
  3. Fall back to a custom server, covered next — the heaviest option, and one you should reach for last.

When you actually need a custom server

A custom server means booting Next.js programmatically from your own Node.js entrypoint instead of next start owning the process. It's the least common of the escape hatches above, and for good reason: it opts you out of some automatic optimizations Next.js otherwise handles for you, and it's incompatible with certain deployment adapters.

// server.js
const { createServer } = require("http");
const next = require("next");

const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  createServer((req, res) => {
    handle(req, res);
  }).listen(3000, () => {
    console.log("Ready on http://localhost:3000");
  });
});

Reach for this only after ruling out the Proxy-based alternatives above. It's a legitimate tool for genuinely custom routing or protocol needs (WebSocket servers sharing a process, for instance), not a default starting point.

Environment variables: server-only by default, inlined if public

By default, every environment variable in a Next.js app is server-only. To expose one to the browser, you prefix it with NEXT_PUBLIC_ — and that's the detail that trips people up when self-hosting with Docker, because NEXT_PUBLIC_ variables get inlined into the JavaScript bundle at build time, not read at runtime.

That matters a lot for a common self-hosting pattern: building one Docker image and promoting it through staging, then production, with different config per environment. If a value is NEXT_PUBLIC_, baking a single image and swapping env vars per environment will not work for that value — it was already burned into the bundle when you ran next build. You'd need to rebuild per environment, or avoid making that particular value public.

Server-only variables don't have this constraint. Read at runtime during dynamic rendering, they reflect whatever value is present in the container's environment when the request comes in:

// app/page.tsx
import { connection } from "next/server";

export default async function Component() {
  await connection();
  // cookies, headers, and other request-time APIs opt this
  // component into dynamic rendering — so this env var is
  // evaluated per-request, at runtime, not baked in at build time
  const value = process.env.MY_VALUE;
  // ...
}

The practical takeaway: keep NEXT_PUBLIC_ variables to values that are genuinely fine to be identical across every environment (a public API base URL, an analytics ID), and keep anything environment-specific server-only, so your "one image, many environments" Docker workflow actually holds.

Caching and ISR: the part that changes the most between platforms

This is the section where self-hosting diverges most from a managed platform, so it's worth understanding the defaults precisely before you touch anything.

By default, Next.js's server-side cache — covering both fetch/data caching and Incremental Static Regeneration output — lives on the local filesystem of each server instance, with an in-memory layer on top (defaulting to 50MB). For a single next start process with a persistent disk, this works perfectly with no configuration. The moment you run more than one instance — multiple containers behind a load balancer, a Kubernetes deployment with several pods — this default becomes a liability rather than a convenience, because each instance's cache is independent. One pod revalidates a page; the other three keep serving the old version until they each independently decide to revalidate on their own schedule.

What gets cached automatically, and how

Three response-header behaviors happen without any configuration on your part:

  • Immutable static assets (anything with a content hash in the filename, like statically-imported images) get Cache-Control: public, max-age=31536000, immutable — cacheable forever, safely, because the filename itself changes if the content does. This one can't be overridden.
  • ISR pages get Cache-Control: s-maxage=<revalidate>, stale-while-revalidate where <revalidate> is whatever you configured. If you set revalidate: false, the effective duration defaults to one year.
  • Dynamically rendered pages get Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate — explicitly telling any CDN or proxy in front of you not to cache this, because it may contain per-user data. Draft Mode responses fall into this bucket too.

If you're running a CDN or reverse proxy in front of Next.js and want it to actually respect ISR's stale-while-revalidate semantics, that proxy has to understand and forward those headers correctly — this is a separate, deeper topic covered in the CDN caching guide, and it's easy to lose ISR's benefit silently if your proxy layer doesn't cooperate with it.

Replacing the default cache with a custom handler

For anything beyond a single instance, you configure a custom cache handler and disable the default in-memory layer:

// next.config.js
module.exports = {
  cacheHandler: require.resolve("./cache-handler.js"),
  cacheMaxMemorySize: 0, // disable default in-memory caching
};
// cache-handler.js
const cache = new Map();

module.exports = class CacheHandler {
  constructor(options) {
    this.options = options;
  }

  async get(key) {
    // in production, this would read from Redis, S3, or similar
    return cache.get(key);
  }

  async set(key, data, ctx) {
    // in production, this would write to durable, shared storage
    cache.set(key, {
      value: data,
      lastModified: Date.now(),
      tags: ctx.tags,
    });
  }

  async revalidateTag(tags) {
    tags = [tags].flat();
    for (const [key, value] of cache) {
      if (value.tags.some((tag) => tags.includes(tag))) {
        cache.delete(key);
      }
    }
  }

  resetRequestCache() {}
};

The in-memory Map above is a teaching example, not something to ship. In production, back it with Redis, a shared filesystem, or object storage — the official Redis cache handler example is the reference implementation most teams start from. What this buys you is consistency: every pod reads and writes the same backing store, so a revalidation triggered on one instance is immediately visible to the others, rather than becoming eventually-consistent on whatever schedule each pod happens to hit.

One detail worth internalizing: revalidatePath isn't a separate cache mechanism — it's a thin convenience wrapper that calls revalidateTag with a special implicit tag scoped to that page. If you're implementing a custom handler, revalidateTag is the one method that actually needs to be correct; revalidatePath rides on top of it for free.

Build Cache and consistent build IDs

Every next build generates a build ID, used to identify exactly which build is running. If you deploy the same build artifact to multiple containers, this is automatic and you don't need to think about it.

Where this bites people: rebuilding separately for each environment (staging, then production) instead of promoting one artifact through both. If each environment gets its own build, each gets a different build ID by default — which breaks the version-skew protection described below, because the client and server no longer agree on what "current" means across environments that are supposed to be the same release. Fix it with a deterministic ID tied to something stable, like your git commit hash:

// next.config.js
module.exports = {
  generateBuildId: async () => {
    return process.env.GIT_HASH;
  },
};

If you've configured deploymentId (below), it takes over this role entirely and generateBuildId is ignored — version skew detection shifts to using the deployment ID instead.

Running multiple server instances

This is the section that most self-hosting write-ups skip, and it's the one that actually determines whether your production deployment behaves correctly under normal operation — rolling deploys, autoscaling, and load-balanced replicas all count as "multiple instances," even if you never think of your setup that way.

Server Function encryption keys have to match across instances

Next.js encrypts the closure variables of Server Functions before sending references to the client, and by default generates a fresh encryption key per build. That's fine for a single instance. The moment you run multiple instances — even just two replicas of the same build — a mismatch becomes possible: a Server Function serialized by instance A can't be decrypted by instance B if they don't share a key, and the client sees a cryptic "Failed to find Server Action" error that has nothing obviously to do with encryption.

Fix it by pinning a shared key at build time:

NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-generated-key next build

The key needs to be base64-encoded and a valid AES length (16, 24, or 32 bytes — Next.js defaults to generating 32-byte keys). Generate it once, store it as a secret in whatever secrets manager your infrastructure uses, and inject it identically at every build across every instance meant to serve the same release.

deploymentId for version-skew protection

Set a deploymentId and Next.js starts tracking which build each client is actually talking to:

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

With this set, static assets get a ?dpl=<deploymentId> query parameter, client navigations send an x-deployment-id header, and the server compares the two. If they don't match — because a rolling deploy replaced the server mid-session, say — Next.js forces a full page reload instead of attempting a client-side navigation against assets or Server Functions that may no longer exist on the new instance. You lose in-memory component state on that reload (useState resets), but URL state and anything in localStorage survives, and — critically — the user doesn't hit a broken app.

Skip this and you'll eventually see the class of bug where a deploy goes out mid-session and some fraction of users get "chunk load failed" or Server Function errors that are impossible to reproduce locally, because your dev environment never has two different builds running at once.

Shared cache, one more time

Multi-server deployments need the custom cache handler from the section above — worth repeating here because it's easy to configure it once for ISR/data caching and forget that the newer 'use cache: remote' directive has its own handler configuration path via cacheHandlers, separate from the classic cacheHandler option. If you're using Cache Components, check that config surface specifically rather than assuming your existing cache handler automatically covers it.

Streaming needs your whole infrastructure to cooperate

The App Router streams responses by default wherever Suspense boundaries are involved, and this works self-hosted — but only if every layer between your Next.js process and the client actually passes chunks through instead of buffering them.

nginx buffers responses by default, which silently defeats streaming: instead of the client receiving content progressively, nginx waits for the full response before forwarding anything. Disable it explicitly:

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

But nginx is rarely the only hop. If there's a load balancer in front of your reverse proxy — and there almost always is in production — it needs to support chunked transfer encoding or HTTP/2 streaming too. This is where a lot of self-hosted setups quietly fail: AWS's Application Load Balancer, when paired with a Lambda integration, buffers responses by default regardless of what your app or nginx config say. The failure mode isn't an error — it's just that streaming silently stops working, pages that should show a loading state progressively instead appear all at once (or time out waiting for the full render), and you'll spend a while looking in the wrong place before realizing the buffering is happening a layer above your application entirely.

If you're using Partial Prerendering, this matters even more than for ordinary Suspense streaming: without working streaming end-to-end, the static shell and the dynamic content get glued together and delivered as one blocking response, which erases PPR's entire time-to-first-byte advantage. You'd have gone through the trouble of adopting a more sophisticated rendering model and gotten none of its benefit, purely because of a load balancer setting three layers removed from your Next.js code.

Multi-instance cache tag coordination

Beyond the shared cache handler, there's a subtler multi-instance problem specific to on-demand revalidation: calling revalidateTag() on one instance only invalidates that instance's view of the cache by default. The other instances keep serving stale content until they happen to independently notice the invalidation on their own.

The fix is implementing refreshTags() on your custom cache handler — a method invoked before each request that syncs tag-invalidation state from shared storage (Redis being the usual choice) so every instance learns about a revalidation promptly, not eventually. If you've implemented a custom cache handler and are using on-demand revalidation across multiple instances but haven't implemented refreshTags(), this is very likely a live bug in your deployment right now — it just doesn't announce itself as one, because "instance B is a little stale" doesn't throw an error, it just serves the wrong content quietly.

Cache Components isn't platform-locked

Worth stating explicitly since it's a common point of confusion: Cache Components is not a Vercel-only or CDN-only feature. It works the same way under next start, in a Docker container, on bare metal — anywhere Next.js runs as a server process.

Usage with CDNs

When you put a CDN in front of a self-hosted Next.js deployment, the Cache-Control headers described earlier are what the CDN uses to decide what it's allowed to cache. Pages using dynamic APIs get Cache-Control: private, explicitly telling the CDN not to cache them. Fully static pages get Cache-Control: public, and — this is the detail worth remembering — that's not something you have to opt into. It's the default behavior of next build for any route that doesn't touch a dynamic API. Automatic Static Optimization means you get CDN-cacheable output for free on any route that qualifies, with zero configuration.

If your route genuinely doesn't need per-request dynamism, this is the cheapest performance win available to you: don't reach for a dynamic API you don't need, and the route becomes CDN-cacheable automatically. The deeper mechanics of cache variability, graceful degradation, and how far CDN caching can go with Next.js today are their own topic, covered in the dedicated CDN caching guide — this section is really just the on-ramp to that one.

after() and graceful shutdown

after() — for running code after a response has been sent, useful for logging or analytics you don't want blocking the user-visible response — is fully supported self-hosted under next start.

The one operational detail to get right: when you stop the server (a rolling deploy, a scale-down event, a container restart), send SIGINT or SIGTERM and then wait, rather than killing the process immediately. Next.js will finish in-flight requests and run any pending after() callbacks before actually exiting — but only if your orchestration layer gives it the time to do so. Kubernetes, ECS, and most container platforms default to a fairly short grace period before sending SIGKILL; bump that to somewhere in the 10–30 second range so pending background work — that analytics call, that log flush — actually completes instead of getting silently truncated mid-deploy.

Key Takeaways

ConcernSingle instanceMultiple instances
Image OptimizationWorks with zero configSame — watch glibc memory on Linux
Proxy (formerly Middleware)Works with zero configSame, per-instance
Data/ISR cacheLocal disk + memory, automaticNeeds a custom cache handler backed by shared storage
On-demand revalidationAutomaticNeeds refreshTags() for cross-instance consistency
Server Function encryptionAutomatic per buildMust pin NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
Rolling deploysNot applicableSet deploymentId for version-skew protection
Streaming/SuspenseWorks if reverse proxy allows itSame, but check every hop (LB included)
after() on shutdownNeeds graceful SIGTERM handlingSame, per instance

Self-hosting Next.js isn't harder than deploying anywhere else — it's just less forgiving of infrastructure you haven't thought about. A single instance with persistent disk will genuinely work with almost no configuration. The moment you scale horizontally, though, every one of these sections stops being optional reading and starts being the difference between a deployment that behaves correctly and one that fails in ways that are maddening to diagnose precisely because none of the individual pieces are broken — they're just no longer talking to each other.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper