🚢 Chapter 24: Deployment & Self-Hosting
Getting a Next.js 16 app into production — on Vercel, in Docker, on your own server, or as static files.
📖 Plain English Explanation
There's a persistent myth that Next.js only really works on Vercel. It isn't true, but it isn't baseless either — Vercel builds Next.js, and some features are one checkbox there and real work elsewhere.
Here's the honest picture:
| Deployment | Setup effort | What you give up |
|---|---|---|
| Vercel | Connect a repo | Cost at scale; vendor coupling |
Node server (standalone) | Moderate | You operate it; ISR needs shared storage across instances |
| Docker / Kubernetes | Moderate | Same, plus container ops |
| Static export | Easy | No server: no Server Actions, no ISR, no proxy, no image optimization |
| Adapters | Varies | Platform-specific gaps |
Nothing about the App Router requires Vercel. Server Components, Server Actions, streaming, and PPR all run on plain Node. What Vercel gives you for free is the operational side: a CDN in front of your static shells, durable ISR storage shared across instances, and image optimization at scale.
Pick based on your team, not on ideology.
▲ Vercel
The path of least resistance.
npm i -g vercel
vercel # preview deploy
vercel --prod # production
Or connect the Git repo and every push deploys, with a preview URL per pull request.
What you get without configuring anything:
- Static shells and assets on a global CDN
- Serverless functions for dynamic rendering
- ISR with durable, shared storage
- Image optimization
- Automatic HTTPS, preview deployments, rollbacks
Set environment variables in the dashboard or the CLI:
vercel env add DATABASE_URL production
The realistic downside is cost. Bandwidth, function invocations, and image optimizations are metered. A content site with heavy traffic can get expensive, and that's usually the point at which teams evaluate self-hosting.
🖥️ Self-hosting: output: 'standalone'
This is the important one to understand, because it's what makes everything else possible.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
}
export default nextConfig
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
next build now produces .next/standalone/ — a self-contained Node server including only the dependencies actually used. No node_modules to ship, often a tenth the size.
npm run build
# copy the assets standalone doesn't include
cp -r public .next/standalone/public
cp -r .next/static .next/standalone/.next/static
node .next/standalone/server.js
Those two cp lines are the thing everyone forgets on their first attempt. Standalone deliberately excludes static assets so you can serve them from a CDN instead — but if you're serving from Node, you need to copy them in.
🐳 Docker
A production Dockerfile with a multi-stage build:
# ---------- deps ----------
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---------- build ----------
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* vars are inlined at BUILD time — they must be present here
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---------- runtime ----------
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
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"]
# .dockerignore
node_modules
.next
.git
.env*.local
npm-debug.log
README.md
Points worth understanding:
HOSTNAME=0.0.0.0 — without it the server binds to localhost inside the container and nothing outside can reach it. This is the second-most-common Docker mistake after forgetting to copy static/.
Non-root user — a container running as root is a container escape away from a host compromise.
NEXT_PUBLIC_* at build time — these are inlined into the client bundle during next build. They cannot be changed at runtime. Server-only variables (DATABASE_URL) are read at runtime and belong in the runtime environment, not the build.
docker build \
--build-arg NEXT_PUBLIC_SITE_URL=https://acme.com \
-t my-app .
docker run -p 3000:3000 \
-e DATABASE_URL=postgresql://… \
-e SESSION_SECRET=… \
my-app
docker-compose for local production testing
# docker-compose.yml
services:
web:
build:
context: .
args:
NEXT_PUBLIC_SITE_URL: http://localhost:3000
ports: ['3000:3000']
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/app
SESSION_SECRET: ${SESSION_SECRET}
depends_on: [db]
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
volumes: ['pgdata:/var/lib/postgresql/data']
volumes:
pgdata:
💾 Caching and ISR when self-hosting
The part that actually differs from Vercel.
By default, Next.js caches ISR output to the filesystem of the machine running the server. With one instance, that's fine. With several instances behind a load balancer, each has its own cache, so users see different versions and revalidation on one node doesn't affect the others.
The fix is a shared cache handler:
// cache-handler.mjs
import { createClient } from 'redis'
const client = createClient({ url: process.env.REDIS_URL })
await client.connect()
export default class CacheHandler {
async get(key) {
const value = await client.get(key)
return value ? JSON.parse(value) : null
}
async set(key, data, ctx) {
await client.set(
key,
JSON.stringify({ value: data, lastModified: Date.now(), tags: ctx.tags }),
ctx.revalidate ? { EX: ctx.revalidate } : undefined
)
}
async revalidateTag(tags) {
const list = Array.isArray(tags) ? tags : [tags]
for (const tag of list) {
// delete every key carrying this tag
// (a real implementation maintains a tag → keys index)
}
}
}
// next.config.ts
const nextConfig: NextConfig = {
cacheHandler: require.resolve('./cache-handler.mjs'),
cacheMaxMemorySize: 0, // disable the in-memory layer; Redis is the source of truth
}
For Cache Components' use cache: remote, the equivalent is cacheHandlers — same idea, configured per directive.
Also note from Chapter 14: cache entries are keyed by build ID, so every deploy starts cold. That's true on every platform. unstable_cache and the fetch cache do persist across deploys.
Consistent build IDs across instances
If you deploy the same build to several machines, they must agree on the build ID or they'll invalidate each other's caches:
// next.config.ts
const nextConfig: NextConfig = {
generateBuildId: async () => process.env.GIT_SHA ?? 'development',
}
📄 Static export
For sites that need no server at all:
// next.config.ts
const nextConfig: NextConfig = {
output: 'export',
images: { unoptimized: true }, // no optimizer without a server
}
npm run build # produces out/
Deploy out/ anywhere — S3, GitHub Pages, Netlify, nginx, a USB stick.
What you lose:
❌ Server Actions
❌ Route Handlers (except GET, prerendered)
❌ proxy.ts
❌ ISR and on-demand revalidation
❌ dynamic = 'force-dynamic'
❌ cookies(), headers(), draftMode()
❌ Image optimization
❌ use cache / Cache Components
That's most of this book. Static export suits documentation sites, marketing pages, and blogs where every route is fully known at build time — and nothing else.
// Every dynamic route must enumerate its params
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((p) => ({ slug: p.slug })) // ALL of them, not just popular ones
}
🔌 Build Adapters
⚠️ New in Next.js 16
Adapters let a hosting platform hook into the build to transform config and output. adapterPath was promoted to a stable top-level option in 16.2:
// next.config.ts
const nextConfig: NextConfig = {
adapterPath: require.resolve('./my-adapter.js'),
}
Unless you're building deployment tooling, you'll consume an adapter your platform publishes rather than write one. Worth knowing it exists — it's how platforms other than Vercel get first-class support for PPR, ISR, and image optimization.
📡 Observability
instrumentation.ts
Runs once when the server starts — the right place for monitoring setup:
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { NodeSDK } = await import('@opentelemetry/sdk-node')
const { getNodeAutoInstrumentations } = await import(
'@opentelemetry/auto-instrumentations-node'
)
const { OTLPTraceExporter } = await import(
'@opentelemetry/exporter-trace-otlp-http'
)
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}),
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
}
}
export function onRequestError(
error: Error,
request: { path: string; method: string },
context: { routerKind: string; routePath: string }
) {
console.error('Request error', {
message: error.message,
path: request.path,
route: context.routePath,
})
// forward to Sentry / Datadog / your log pipeline
}
// instrumentation.js
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { NodeSDK } = await import('@opentelemetry/sdk-node')
const { getNodeAutoInstrumentations } = await import(
'@opentelemetry/auto-instrumentations-node'
)
const sdk = new NodeSDK({
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
}
}
export function onRequestError(error, request, context) {
console.error('Request error', { message: error.message, path: request.path })
}
onRequestError is the hook that catches every server-side error, including ones inside Server Components — pair it with the error.digest from Chapter 9 to correlate a user's error reference with a full stack trace.
Client-side instrumentation
// instrumentation-client.ts
export function register() {
window.addEventListener('unhandledrejection', (event) => {
// report to your error service
})
}
Health check
// app/api/health/route.ts
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
await db.$queryRaw`SELECT 1`
return Response.json({ status: 'ok', uptime: process.uptime() })
} catch {
return Response.json({ status: 'degraded' }, { status: 503 })
}
}
Point your load balancer or Kubernetes liveness probe at it.
🔑 Environment variables in production
The distinction that causes the most confusion:
NEXT_PUBLIC_* inlined at BUILD time into the client bundle
changing it requires a rebuild
never a secret
everything else read at RUNTIME on the server
changing it requires a restart, not a rebuild
safe for secrets
To read a server variable at request time rather than at build time — useful when self-hosting and you want to change config without rebuilding:
// app/config/page.tsx
import { connection } from 'next/server'
export default async function Page() {
await connection() // opt out of prerendering
const flag = process.env.FEATURE_FLAG // read per request
return <p>{flag}</p>
}
⚠️ Changed in Next.js 16
serverRuntimeConfigandpublicRuntimeConfigwere removed.js// ❌ removed module.exports = { serverRuntimeConfig: { dbUrl: process.env.DATABASE_URL }, publicRuntimeConfig: { apiUrl: '/api' }, }tsx// ✅ read env vars directly on the server const dbUrl = process.env.DATABASE_URL // ✅ NEXT_PUBLIC_ for client-accessible values const apiUrl = process.env.NEXT_PUBLIC_API_URL
🌐 Behind a reverse proxy
nginx in front of a standalone server:
upstream nextjs {
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 443 ssl http2;
server_name acme.com;
ssl_certificate /etc/letsencrypt/live/acme.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/acme.com/privkey.pem;
# Immutable build assets
location /_next/static/ {
proxy_pass http://nextjs;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
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;
# Do NOT buffer — it breaks streaming
proxy_buffering off;
proxy_read_timeout 300s;
}
}
proxy_buffering off is essential. With buffering on, nginx waits for the complete response before sending anything — which silently destroys streaming and PPR. Your pages will still work; they'll just be as slow as before you did any of the work in Chapters 8 and 16.
X-Forwarded-Proto matters too: without it, secure cookies won't be set correctly behind TLS termination.
✅ Production checklist
Build
□ next build passes with no warnings
□ TypeScript and lint clean in CI
□ E2E suite passes against the production build
□ Bundle analyzed; nothing unexpected in the client
Config
□ output: 'standalone' if self-hosting
□ images.remotePatterns lists every external host
□ security headers set
□ redirects for any changed URLs
□ generateBuildId pinned to the commit SHA (multi-instance)
Environment
□ every required variable present in the target environment
□ NEXT_PUBLIC_* set at BUILD time
□ secrets in a secret manager, not in the image
□ SESSION_SECRET long, random, and rotated
Data
□ migrations run before the new version serves traffic
□ connection pooling sized for your instance count
□ backups verified by an actual restore
Observability
□ instrumentation.ts wired to your APM
□ onRequestError forwarding to error tracking
□ /api/health returning real status
□ Core Web Vitals collected from real users
Caching
□ shared cache handler if running multiple instances
□ CDN in front, with correct headers on /_next/static
□ ISR verified in production (x-nextjs-cache)
Security
□ HTTPS with auto-renewing certificates
□ container runs as non-root
□ rate limiting on auth endpoints
□ dependencies audited
□ no debug endpoints or source maps exposed
⚠️ Common Pitfalls
1. Forgetting to copy static/ and public/
cp -r public .next/standalone/public
cp -r .next/static .next/standalone/.next/static
Symptom: the page renders with no CSS and no images. Everyone hits this once.
2. Missing HOSTNAME=0.0.0.0 in Docker
The server binds to localhost inside the container and the outside world can't reach it.
3. NEXT_PUBLIC_* set at runtime
docker run -e NEXT_PUBLIC_API_URL=… my-app # ❌ too late
It was inlined at build time. Pass it as a --build-arg.
4. proxy_buffering on in nginx
Streaming and PPR silently stop working. No error — just slower pages.
5. Filesystem ISR cache across multiple instances
Each instance caches independently, so users see different versions and a revalidation on one node doesn't reach the others. Use a shared cache handler.
6. Expecting use cache to survive a deploy
It doesn't — the build ID is in every cache key.
7. Static export with dynamic features
Server Actions, cookies(), and ISR all fail. If you need them, you need a server.
8. Running migrations after the deploy
The new code hits a schema that doesn't exist yet. Run migrations first, and make them backward compatible so the old version keeps working during the rollout.
9. Secrets baked into the image
docker history shows every build arg. Secrets belong in the runtime environment or a secret manager.
10. No health check
Your orchestrator can't tell a hung process from a working one, so it never restarts anything.
🎯 When & Why to Use
Vercel when:
✅ You want zero ops
✅ Preview deploys per PR matter
✅ Traffic is moderate, or the cost is worth the time saved
Docker / Node when:
✅ You have infrastructure and people to run it
✅ Cost at your traffic level justifies it
✅ Compliance requires specific hosting
✅ It has to sit next to other services
Static export when:
✅ Docs, marketing, or a blog with no server needs
✅ You want to host on S3 or GitHub Pages
❌ Never for anything with auth, mutations, or personalization
Adapters when:
✅ Your platform publishes one
🏋️ Mini Practice Problems
Problem 1: Fix the Dockerfile
FROM node:22
WORKDIR /app
COPY . .
RUN npm install && npm run build
EXPOSE 3000
CMD ["npm", "start"]
Five problems. Name them and rewrite it.
Problem 2: Diagnose
Three instances behind a load balancer. Users report that refreshing a blog post sometimes shows old content and sometimes new, seemingly at random. Explain the cause and give the fix.
Problem 3: Choose the target
- A. A company docs site, updated via Git, no auth
- B. A SaaS dashboard with auth and real-time data
- C. A high-traffic news site with ISR and heavy image use
- D. An internal tool that must run inside a VPC
For each: deployment target, and one thing you'd need to configure that you wouldn't elsewhere.
Problem 4: Write the runbook
Your app is on a single Node server behind nginx. Write the deploy procedure: build, migrations, cutover, health verification, and rollback. What's the failure mode at each step?
💼 Interview Notes
Common Questions
Q: Does Next.js require Vercel?
No. output: 'standalone' produces a self-contained Node server that runs anywhere. Vercel provides the operational layer — CDN, durable shared ISR storage, image optimization at scale — which you'd otherwise configure yourself.
Q: What does output: 'standalone' do?
It traces the dependencies your app actually uses and emits a minimal Node server in .next/standalone/, typically a tenth the size of a full install. You must copy public/ and .next/static/ in yourself, because standalone assumes you may serve them from a CDN.
Q: What breaks with static export?
Everything requiring a server: Server Actions, non-GET Route Handlers, proxy, ISR, cookies()/headers(), image optimization, and Cache Components. It's for fully-known-at-build-time sites only.
Q: How does ISR work with multiple instances?
Badly, by default — each instance caches to its own filesystem, so users see inconsistent content and revalidation doesn't propagate. Configure a shared cacheHandler backed by Redis or similar, and pin generateBuildId so all instances agree.
Q: Why does streaming stop working behind nginx?
proxy_buffering defaults to on, so nginx waits for the complete response before forwarding it. That collapses streaming and PPR back into a single blocking response. Set proxy_buffering off.
Q: What's the difference between NEXT_PUBLIC_ and other env vars in a container?
NEXT_PUBLIC_ values are inlined into the client bundle during next build, so they must be present as build args and can't change at runtime. Everything else is read from the server's environment at runtime.
Q: What replaced serverRuntimeConfig in Next.js 16?
Nothing — it was removed. Read process.env directly in Server Components, use NEXT_PUBLIC_ for client-accessible values, and call await connection() before reading if you need the value resolved per request rather than at build time.
🏢 Asked at Companies
- Vercel: "A customer wants to self-host. What do they need to build themselves?"
- Shopify: "Design a zero-downtime deploy for a Next.js app with database migrations."
- Datadog: "How do you get full observability into Server Component errors?"
- Cloudflare: "What are the constraints of running Next.js somewhere that isn't a Node server?"
📊 Visual Memory Aid
DEPLOYMENT TARGETS
┌─────────────┬────────┬──────┬─────┬───────┬────────┐
│ │ Actions│ ISR │Proxy│ Image │ Ops │
├─────────────┼────────┼──────┼─────┼───────┼────────┤
│ Vercel │ ✅ │ ✅ │ ✅ │ ✅ │ none │
│ Node/Docker │ ✅ │ ⚠️ │ ✅ │ ✅ │ yours │
│ Static │ ❌ │ ❌ │ ❌ │ ❌ │ none │
└─────────────┴────────┴──────┴─────┴───────┴────────┘
⚠️ = needs a shared cache handler for multi-instance
STANDALONE BUILD
next build
└── .next/standalone/ ← minimal server + traced deps
└── server.js
YOU MUST COPY:
public/ → .next/standalone/public
.next/static/ → .next/standalone/.next/static
Forget these → page renders with no CSS 🎨💀
ENV VAR TIMING
BUILD TIME RUNTIME
────────── ───────
NEXT_PUBLIC_API_URL DATABASE_URL
inlined into JS read from process.env
--build-arg -e / secret manager
needs a rebuild needs a restart
nginx GOTCHA
proxy_buffering on (default)
└── waits for the FULL response
streaming ❌ PPR ❌ fast shell ❌
proxy_buffering off
└── forwards chunks as they arrive ✅
🎯 Key Takeaways
- Next.js runs anywhere.
output: 'standalone'produces a minimal Node server — Vercel sells the operations, not the capability. - Copy
public/and.next/static/into the standalone output. This is the first thing everyone gets wrong when self-hosting. - Multiple instances need a shared cache handler and a pinned
generateBuildId, or ISR produces inconsistent content across nodes. proxy_buffering offbehind nginx. Otherwise streaming and PPR silently degrade to a single blocking response.NEXT_PUBLIC_*is baked in at build time; everything else is read at runtime.serverRuntimeConfigandpublicRuntimeConfigwere removed in Next.js 16 — use environment variables directly, withconnection()when you need a per-request read.
Next Chapter: Upgrading to Next.js 16 →
Practice: Containerize an app with the multi-stage Dockerfile, run it behind nginx locally with proxy_buffering off, and verify with curl -N that HTML arrives in chunks. Then turn buffering on and watch streaming disappear.