Dev Logs
/Next.js/ Chapter 24: Deployment & Self-Hosting
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
    • Plain English Explanation
    • ▲ Vercel
    • Self-hosting: output: 'standalone'
    • Docker
    • docker-compose for local production testing
    • Caching and ISR when self-hosting
    • Consistent build IDs across instances
    • Static export
    • Build Adapters
    • Observability
    • instrumentation.ts
    • Client-side instrumentation
    • Health check
    • Environment variables in production
    • Behind a reverse proxy
    • Production checklist
    • Common Pitfalls
    • . Forgetting to copy static/ and public/
    • . Missing HOSTNAME=0.0.0.0 in Docker
    • . NEXT_PUBLIC_* set at runtime
    • . proxy_buffering on in nginx
    • . Filesystem ISR cache across multiple instances
    • . Expecting use cache to survive a deploy
    • . Static export with dynamic features
    • . Running migrations after the deploy
    • . Secrets baked into the image
    • . No health check
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Fix the Dockerfile
    • Problem 2: Diagnose
    • Problem 3: Choose the target
    • Problem 4: Write the runbook
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🚢 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:

DeploymentSetup effortWhat you give up
VercelConnect a repoCost at scale; vendor coupling
Node server (standalone)ModerateYou operate it; ISR needs shared storage across instances
Docker / KubernetesModerateSame, plus container ops
Static exportEasyNo server: no Server Actions, no ISR, no proxy, no image optimization
AdaptersVariesPlatform-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.

bash
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:

bash
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.

ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
}

export default nextConfig
js
// 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.

bash
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:

dockerfile
# ---------- 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"]
dockerfile
# .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.

bash
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

yaml
# 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:

ts
// 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)
    }
  }
}
ts
// 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:

ts
// next.config.ts
const nextConfig: NextConfig = {
  generateBuildId: async () => process.env.GIT_SHA ?? 'development',
}

📄 Static export

For sites that need no server at all:

ts
// next.config.ts
const nextConfig: NextConfig = {
  output: 'export',
  images: { unoptimized: true },   // no optimizer without a server
}
bash
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.

tsx
// 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:

ts
// 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:

ts
// 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
}
js
// 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

ts
// instrumentation-client.ts
export function register() {
  window.addEventListener('unhandledrejection', (event) => {
    // report to your error service
  })
}

Health check

ts
// 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:

tsx
// 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

serverRuntimeConfig and publicRuntimeConfig were 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:

nginx
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/

bash
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

bash
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

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

  1. Next.js runs anywhere. output: 'standalone' produces a minimal Node server — Vercel sells the operations, not the capability.
  2. Copy public/ and .next/static/ into the standalone output. This is the first thing everyone gets wrong when self-hosting.
  3. Multiple instances need a shared cache handler and a pinned generateBuildId, or ISR produces inconsistent content across nodes.
  4. proxy_buffering off behind nginx. Otherwise streaming and PPR silently degrade to a single blocking response.
  5. NEXT_PUBLIC_* is baked in at build time; everything else is read at runtime. serverRuntimeConfig and publicRuntimeConfig were removed in Next.js 16 — use environment variables directly, with connection() 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.


PreviousChapter 23: TestingNextChapter 25: Upgrading to Next.js 16

Open source, free forever. Built by iammhador.

Contribute on GitHub