Deployment and Self-Hosting
The idea in one sentence
Section titled “The idea in one sentence”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 versus self-hosting
Section titled “Vercel versus self-hosting”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.
# Self-hosting: build once, then run a long-lived Node servernext buildnext start -p 3000Minimum 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.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { output: 'standalone',}
export default nextConfigA 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:1FROM node:20-alpine AS depsWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci
FROM node:20-alpine AS builderWORKDIR /appCOPY --from=deps /app/node_modules ./node_modulesCOPY . .RUN npm run build
FROM node:20-alpine AS runnerWORKDIR /appENV NODE_ENV=productionRUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjsCOPY --from=builder /app/public ./publicCOPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/staticUSER nextjsEXPOSE 3000ENV PORT=3000ENV HOSTNAME=0.0.0.0CMD ["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 whoaminextjsUSER 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.
Caching and ISR across instances
Section titled “Caching and ISR across instances”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:
| Key | Configures | Introduced |
|---|---|---|
cacheHandler (singular) | The legacy full-route / ISR cache — the one behind revalidatePath, revalidateTag, and static regeneration | Pre-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 handler | v16.0.0 |
// next.config.ts — legacy full-route/ISR cache, shared across instancesmodule.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 nextConfigIf 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.
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 0msINSTRUMENTATION_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).
Server Actions across multiple instances
Section titled “Server Actions across multiple instances”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.
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-generated-key next buildSet 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.
Go-live checklist
Section titled “Go-live checklist”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(andcacheHandlersif you use'use cache') before scaling past one instance. - Mutate through Server Actions and call
updateTag/revalidateTag/revalidatePathso caches stay correct after writes — and setNEXT_SERVER_ACTIONS_ENCRYPTION_KEYbefore 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 onlyNEXT_PUBLIC_values, and only as a static literal. - Wire
instrumentation.ts’sonRequestErrorto a real observability provider before the first incident, not after.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Vercel | Zero-config CDN, image optimization, runtime | Less control over the underlying infrastructure |
Self-hosting with output: 'standalone' | Small image, full infrastructure control | You own the reverse proxy, TLS, and process supervision |
| Single self-hosted instance | Default filesystem cache just works | No redundancy — one process, one point of failure |
| Multiple self-hosted instances | Redundancy, horizontal scaling | Needs 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 explicitly | Removes ambiguity about what the default resolves to | One more line to maintain, even though it matches the built-in default today |
Common Misconceptions
Section titled “Common Misconceptions”- The standalone runner stage can just run
next startornpx next. It can’t —output: 'standalone'traces only whatserver.jsitself needs; thenextCLI and most ofnode_modulesare deliberately left out. Proven:CMD ["npx", "next", "start"]in a standalone runner fails immediately withsh: next: not found. cacheHandlerandcacheHandlersconfigure 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 inv16.0.0. Setting one has no effect on the other.HOSTNAME=0.0.0.0is required or the container won’t be reachable. The standaloneserver.jsalready defaults to0.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
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated Dockerfile or deployment config shows up, check for these before trusting it:
- Does the runner stage
CMDrunnext startornpx nextinstead ofnode server.js? Withoutput: 'standalone', that fails at container start — thenextbinary isn’t there. - Does the Dockerfile end up running as root (no
USERdirective)? Confirm withdocker exec <container> whoami— it should print a non-root name, notroot. - Does the config set
cacheHandlerwhen 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_KEYpinned? 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.tsexist but only log toconsoleinsideonRequestError? 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 builderWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run build
FROM node:20-alpine AS runnerWORKDIR /appCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/.next/static ./.next/staticCOPY --from=builder /app/public ./publicCMD ["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.