Skip to content

Deployment and Self-Hosting

You can deploy to Vercel with zero config or self-host a Node server behind a CDN — but once you run more than one instance, tag-based caching and Server Actions need coordination (a shared cache handler, a fixed encryption key) so every instance agrees.

Vercel detects a Next.js app and wires up builds, the CDN, image optimization, and the server runtime with no configuration. Self-hosting gives you full control: you build the app and run next start (a long-lived Node server) yourself, usually behind a CDN or reverse proxy (nginx or similar) that caches static assets and shields the Next.js process from malformed requests and rate-limit concerns.

Terminal window
# Self-hosting: build once, then run a long-lived Node server
next build
next start -p 3000

Minimum Node.js version for Next.js 16: 20.9. Anything older is unsupported, whether you self-host directly or build the base image for a container.

A full multi-stage Dockerfile — built and run

Section titled “A full multi-stage Dockerfile — built and run”

output: 'standalone' makes Next.js trace exactly which files the server needs and copy them into .next/standalone — a tiny image that ships without the full node_modules.

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
}
export default nextConfig

A production Dockerfile splits into three stages — deps installs packages once, builder compiles, runner ships only the traced output as a non-root user:

# Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

Built and run for real in the probe (docker build then docker run -p 3412:3000):

#16 4.993 Route (app)
#16 4.993 ┌ ○ /
#16 4.993 ├ ○ /_not-found
#16 4.993 └ ƒ /boom
#20 writing image sha256:ec357758ea09b2b7e8316a98fb4ea19e3e4290da28400f99be1de5ca06bf6196 done
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3412/
200
$ docker exec nextdd-probe-run whoami
nextjs

USER nextjs is not decorative — whoami inside the running container confirms the process is uid 1001, not root. Note the runner stage’s CMD is node server.js, never next start or npx next: the standalone output ships its own minimal server.js and does not include the next CLI or the rest of node_modules.

HOSTNAME=0.0.0.0: already the default, worth pinning anyway

Section titled “HOSTNAME=0.0.0.0: already the default, worth pinning anyway”

The standalone server.js itself already defaults to 0.0.0.0 when HOSTNAME isn’t set — grepping the generated file confirms it:

const hostname = process.env.HOSTNAME || '0.0.0.0'

Setting ENV HOSTNAME=0.0.0.0 explicitly in the Dockerfile doesn’t change that default — it guards against something else overriding it. Since HOSTNAME is a generic-sounding name, some orchestration layers or shells populate it with something other than an IP (a container ID, a pod name); pinning it in the image removes that class of surprise instead of relying on an inherited value.

The full-route cache and ISR write to Next.js’s server cache; on a single self-hosted next start instance the default filesystem cache is fine. Run multiple instances and each has its own local cache and its own local view of which tags were revalidated — so revalidateTag on one instance leaves the others stale until they independently notice.

cacheHandler vs cacheHandlers — two different knobs

Section titled “cacheHandler vs cacheHandlers — two different knobs”

These two config keys are easy to confuse because they sound like plurals of each other, but they configure different caching systems:

KeyConfiguresIntroduced
cacheHandler (singular)The legacy full-route / ISR cache — the one behind revalidatePath, revalidateTag, and static regenerationPre-16, the original self-hosting cache override
cacheHandlers (plural, an object)Backends for the 'use cache' directive family — default for 'use cache', remote for 'use cache: remote', plus any named custom handlerv16.0.0
// next.config.ts — legacy full-route/ISR cache, shared across instances
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // disable the in-memory cache; use the shared handler
}
// next.config.ts — backends for 'use cache' / 'use cache: remote'
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheHandlers: {
default: require.resolve('./cache-handlers/default-handler.js'),
remote: require.resolve('./cache-handlers/remote-handler.js'),
},
}
export default nextConfig

If your project still uses route segment config and revalidateTag/revalidatePath (no 'use cache' anywhere yet), you only need cacheHandler. Once you adopt 'use cache', backends for it are configured separately through cacheHandlers — setting one does not configure the other. Either way, tag invalidation across instances also needs the handler’s refreshTags() implemented to sync from shared storage (Redis or similar) before each request, since the default in-memory handler only tracks tags locally.

instrumentation.ts: register() and onRequestError, proven

Section titled “instrumentation.ts: register() and onRequestError, proven”

instrumentation.ts at the project root runs once when a new server instance starts, before it accepts traffic, and can also observe every server-side error through onRequestError.

instrumentation.ts
export function register() {
console.log('runtime:', process.env.NEXT_RUNTIME) // 'nodejs' or 'edge'
}
export async function onRequestError(err: unknown, request: any, context: any) {
const message = err instanceof Error ? err.message : String(err)
// ship this to your observability provider instead of console.log
console.log('request error:', message, context.routerKind, context.routeType)
}

Proven end to end in the probe: a route forced dynamic (export const dynamic = 'force-dynamic') that throws, hit with curl, produces this real server log —

✓ Ready in 0ms
INSTRUMENTATION_REGISTER_CALLED nodejs
⨯ Error: probe-boom-error
at <unknown> (.next/server/chunks/ssr/[root-of-the-server]__0v8669b._.js:1:225) {
digest: '3670713050'
}
ON_REQUEST_ERROR_CALLED probe-boom-error App Router render

— confirming register() ran with NEXT_RUNTIME=nodejs before the first request, and onRequestError fired with the thrown error’s message and context.routeType ('render' for a Server Component, also 'route', 'action', or 'proxy' depending on where the error happened).

Next.js encrypts Server Action closure variables before sending them to the client, using a build-time key. If your build produces a different key per instance (the default), an action encrypted by instance A cannot be decrypted by instance B, and the client gets a “Failed to find Server Action” error whenever a rolling deploy or a load balancer routes a follow-up request to a different instance.

Terminal window
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-generated-key next build

Set this once and reuse it for every instance’s build (a base64-encoded 16, 24, or 32-byte AES key — Next.js generates a 32-byte one by default when the variable is absent). The security side of this — what “encrypted” does and doesn’t protect — belongs to Server Actions themselves; see Server Actions Security and Patterns for that half of the picture. This lesson’s half is purely operational: without a shared key, a correct, secure Server Action still breaks the moment you run more than one instance.

This is the last stop, so pull the whole course together before you ship:

  • Keep data fetching in Server Components; only mark a leaf 'use client' when it truly needs interactivity.
  • Choose the rendering mode per route deliberately — static, dynamic, or ISR — and confirm each behaves as expected in a production build.
  • Understand your caching layers (request memoization, Data Cache, full-route cache) and wire a shared cacheHandler (and cacheHandlers if you use 'use cache') before scaling past one instance.
  • Mutate through Server Actions and call updateTag / revalidateTag / revalidatePath so caches stay correct after writes — and set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY before running more than one instance.
  • Gate protected routes with an optimistic proxy redirect AND a Data Access Layer check.
  • Keep secrets unprefixed in .env.local; expose only NEXT_PUBLIC_ values, and only as a static literal.
  • Wire instrumentation.ts’s onRequestError to a real observability provider before the first incident, not after.

Treat every self-hosted instance as disposable and interchangeable, the way a load balancer treats it — any request can land on any instance, at any moment, including mid-rollout. Everything that must survive a request landing on a different instance than the one before it — the cache, the Server Action decryption key, the session — has to live in something shared (Redis, a fixed env var, a signed cookie), never in that one instance’s memory or its one build’s random key.

What you getBenefitCost
VercelZero-config CDN, image optimization, runtimeLess control over the underlying infrastructure
Self-hosting with output: 'standalone'Small image, full infrastructure controlYou own the reverse proxy, TLS, and process supervision
Single self-hosted instanceDefault filesystem cache just worksNo redundancy — one process, one point of failure
Multiple self-hosted instancesRedundancy, horizontal scalingNeeds a shared cacheHandler/cacheHandlers and a fixed NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, or caches go stale and actions start failing
Pinning HOSTNAME=0.0.0.0 explicitlyRemoves ambiguity about what the default resolves toOne more line to maintain, even though it matches the built-in default today
  • The standalone runner stage can just run next start or npx next. It can’t — output: 'standalone' traces only what server.js itself needs; the next CLI and most of node_modules are deliberately left out. Proven: CMD ["npx", "next", "start"] in a standalone runner fails immediately with sh: next: not found.
  • cacheHandler and cacheHandlers configure the same cache. They don’t — cacheHandler (singular) is the legacy full-route/ISR cache override; cacheHandlers (plural, an object) configures backends for the 'use cache' directive family, introduced in v16.0.0. Setting one has no effect on the other.
  • HOSTNAME=0.0.0.0 is required or the container won’t be reachable. The standalone server.js already defaults to 0.0.0.0 — proven by reading the generated file. Pinning it explicitly is still good practice, since it forecloses anything else quietly overriding that default, but it isn’t fixing an actual gap in the default behavior.
  • forbidden()/unauthorized() and Server Actions security are “deployment” concerns to solve here. The operational half (a shared encryption key so multi-instance deploys don’t break) lives in this lesson; the security half (what the encryption does and doesn’t protect against) belongs to authentication and Server Actions security.
graph TD
  A["CDN / reverse proxy"] --> B["Instance 1 (node server.js)"]
  A --> C["Instance 2 (node server.js)"]
  B --> D["Shared cacheHandler / cacheHandlers (Redis)"]
  C --> D
  D --> E["revalidateTag invalidates all instances"]
  B --> F["Same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY"]
  C --> F
Multi-instance self-hosting needs shared cache and a shared key

When a generated Dockerfile or deployment config shows up, check for these before trusting it:

  • Does the runner stage CMD run next start or npx next instead of node server.js? With output: 'standalone', that fails at container start — the next binary isn’t there.
  • Does the Dockerfile end up running as root (no USER directive)? Confirm with docker exec <container> whoami — it should print a non-root name, not root.
  • Does the config set cacheHandler when the project actually uses 'use cache' (or vice versa)? They’re separate systems; setting the wrong one silently leaves the other on its default.
  • Is there a multi-instance deployment (replicas, a load balancer, Kubernetes) with no NEXT_SERVER_ACTIONS_ENCRYPTION_KEY pinned? Each instance gets its own random key by default, so Server Actions intermittently fail depending on which instance handles the follow-up request.
  • Does instrumentation.ts exist but only log to console inside onRequestError? That’s invisible the moment the container’s stdout isn’t being collected anywhere — it needs to ship to a real destination.
  • Is the base image’s Node.js version below 20.9? That’s below the documented minimum for Next.js 16, whether or not the build happens to still succeed today.

🐛 Spot the bug

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["npx", "next", "start"]
Show the answer

The runner stage only copies .next/standalone, .next/static, and public — by design, that’s everything output: 'standalone' traces as necessary to run node server.js. It deliberately does not include the next CLI or the rest of node_modules. Running this exact Dockerfile produces sh: next: not found the moment the container starts, because npx next start needs the full next package that was never copied in. The fix is CMD ["node", "server.js"] — the standalone output already ships its own server entry point.

What does output: standalone produce?
What happens when this container starts?
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["npx", "next", "start"]
What is the difference between cacheHandler and cacheHandlers?
Why does the standalone server.js not strictly need HOSTNAME=0.0.0.0 set in the Dockerfile?
Why does a multi-instance deployment need NEXT_SERVER_ACTIONS_ENCRYPTION_KEY set explicitly?
Minimum supported Node.js version for Next.js 16?