Skip to content
advanced Phase 16 · Deployment and Production Capstone

Self-Hosting with Standalone Output

Run the app on your own servers in a small container.

50m
0 problems
Topic Progress 0%

From 1.2 GB to 200 MB: What `output: 'standalone'` Actually Copies

Your first self-hosted image works, and it weighs 1.2 GB because the build stage installed a full node_modules — TypeScript, ESLint, test runners, every transitive package — and the runtime stage inherited all of it. Builds crawl, pulls crawl, and the attack surface is enormous. Standalone output exists to cut that down to the files your server actually touches.

// next.config.mjs
import path from 'node:path'

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  // monorepo: point tracing at the workspace root (see below)
  outputFileTracingRoot: path.join(__dirname, '../../'),
}

export default nextConfig

With that flag, next build walks your server's import graph and emits .next/standalone/ — server.js, the compiled app code, and a pruned node_modules containing only the files reachable at runtime. It starts with plain Node:

PORT=3000 HOSTNAME=0.0.0.0 node .next/standalone/server.js

The container contract follows from that: server.js reads PORT and HOSTNAME from the environment (set HOSTNAME=0.0.0.0, or a container binds to a hostname the outside world cannot reach), and the next CLI is not in that folder — you cannot run next start or next dev against standalone output. It is a plain Node process, which is exactly why a supervisor can run ten copies of it.

Full node_modules install .next/standalone
Typical size 1.0–1.2 GB ~150–250 MB on node:alpine
Start command npx next start node server.js
Contains build-only tools yes (typescript, eslint, jest) no
What it is your project directory the traced runtime subset

How the pruning happens matters for debugging it. Next.js runs file tracing over your server entry points: it follows static import and require chains, reads each package's package.json for its entry files, and copies what it can prove is reachable. Values assembled at runtime escape that analysis, so a plugin path built from a string — require(\./handlers/${name}.js`)— can be missing from the output while everything still works on your laptop. If a traced file is genuinely required, declare it explicitly rather than hoping static analysis finds it. The same pass decides what happens tonext/image: the optimizer runs inside the standalone server, and the native sharp` library it relies on must be present in the traced output — which it is as long as you installed dependencies normally instead of pruning optional packages.

What standalone deliberately does not include: .next/static (hashed client chunks) and public/. Those are deployment artifacts, so your Dockerfile copies them in explicitly — the subject of the next chapter, and the single most common cause of a "blank page in production but fine locally".

Two production-only failures are born in this chapter's config. In a monorepo, without outputFileTracingRoot, tracing stops at the app package boundary and your image dies at boot with Cannot find module '<repo>/packages/ui/dist/index.js' — shared code above the app directory never got copied, and nothing in next dev hinted at it. Second, standalone output does not carry .next/cache: each instance gets its own ISR and data cache, so revalidateTag('product:42') on one container leaves its siblings serving stale prices until their own timers fire. With more than one replica you need a shared cacheHandler (Redis) with refreshTags() coordination, a CDN honoring s-maxage, or acceptance that revalidation is eventually consistent. The same multi-instance trap hides in server actions: every replica must share NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, or an action encrypted by instance A cannot be decrypted by instance B and the client sees Failed to find Server Action.

Deploying to a Node Platform built the artifact; standalone is how you make it small enough to run anywhere.

The Multi-Stage Dockerfile and the Two COPY Lines People Forget

A multi-stage build exists so the tools that produce the artifact never ship with the artifact. Three stages, and only files explicitly COPY --from='d into the last one survive:

# syntax=docker/dockerfile:1
FROM node:22-alpine AS base
RUN apk add --no-cache libc6-compat

FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci                          # lockfile-exact install, cached by layer

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build                   # full toolchain: typescript, next, eslint

FROM base 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
RUN mkdir .next && chown nextjs:nodejs .next   # server writes prerender/optimizer cache here
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 HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

The order of the copies is the whole lesson. Standalone output omits .next/static and public, so the runner must add both — and the destinations matter: static assets go inside the copied standalone tree at ./.next/static, not somewhere adjacent.

Missing line Symptom in production
COPY .../app/.next/static ./.next/static HTML arrives, then nothing renders; console fills with Failed to load resource: 404 for /_next/static/chunks/main-*.js
COPY .../app/public ./public favicon, robots.txt, and every image in public/ 404 while the app "works"
copying .next wholesale and CMD npm start image balloons back to ~1.2 GB, and node server.js fails because server.js exists only under .next/standalone/

Three more habits keep the image honest. .dockerignore must list node_modules, .next, and .env* — otherwise COPY . . drags your machine's node_modules over the npm ci result (wrong-platform binaries) and bakes .env.local secrets into an image layer that anyone with registry access can extract:

node_modules
.next
.env*
npm-debug.log*
.git
Dockerfile

Never ENV a secret in the Dockerfile: build one image and promote it through staging and production, passing DATABASE_URL and friends at run time — the server-side runtime reads from Managing Secrets Across Environments make the same bytes safe in every environment. Run as a non-root user (USER nextjs) so a compromised process cannot rewrite the image.

The stage order is also a caching strategy. package.json and the lockfile are copied alone before any source file, so Docker reuses the npm ci layer whenever only your code changed — the difference between a 40-second rebuild and a 4-minute one. Reorder those COPY lines and every edit invalidates the dependency install.

Verify before pushing:

docker build -t store:$(git rev-parse --short HEAD) .
docker run --rm -p 3000:3000 -e DATABASE_URL='postgres://...' store:abc1234
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/   # 200

The runner stage never runs npm install — that is deliberate. It has no package manager state to corrupt, no dev dependencies to audit, and nothing that changes between builds except your code. One image, tagged with the commit SHA, promoted from staging to production by digest with only -e variables swapped between them.

A ~200 MB image that starts in under a second is now portable: the same layer runs on a VM, in Kubernetes, or under a local compose file. What it still lacks is anything standing in front of it.

`next start` or `node server.js`? Supervisors, TLS, and the Reverse Proxy

Two ways exist to run a production build on hardware you own, and they differ in what you must provide:

next start standalone + supervisor
Needs on disk full node_modules + complete .next .next/standalone, .next/static, public
Command npx next start -p 3000 node server.js
Typical image ~1–1.2 GB ~200 MB
Scaling one process per port N workers/replicas behind a proxy
When to pick it a single box, quick proofs anything automated or repeated

Either way the process must not be the thing you babysit. PM2 gives you clustering and resurrection:

npm ci && npm run build
pm2 start .next/standalone/server.js --name store -i max   # one worker per CPU
pm2 save && pm2 startup      # come back after a reboot
pm2 reload store             # zero-downtime rolling restart
pm2 logs store --lines 200

systemd does the same job with files you can read during an incident:

[Unit]
Description=Storefront (Next.js)
After=network.target

[Service]
Type=simple
User=nextjs
WorkingDirectory=/srv/store
EnvironmentFile=/etc/store/env          # runtime secrets, root-owned, mode 600
ExecStart=/usr/bin/node .next/standalone/server.js
Restart=always
RestartSec=2
TimeoutStopSec=30                        # SIGTERM, then drain in-flight requests

[Install]
WantedBy=multi-user.target

That TimeoutStopSec matters: on SIGTERM the Next.js server finishes in-flight requests and runs pending after() callbacks, so give it 10–30 seconds before the supervisor escalates to SIGKILL — otherwise a deploy cut mid-checkout corrupts someone's order.

Never expose port 3000 to the internet directly. The reverse proxy is where TLS, abuse filtering, and static caching live:

server {
  listen 443 ssl http2;
  server_name shop.example.com;
  ssl_certificate     /etc/letsencrypt/live/shop.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;

  location /_next/static/ {            # hashed, immutable — cache at the edge
    proxy_pass http://127.0.0.1:3000;
    proxy_cache_valid 200 365d;
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Request-Id $request_id;
    proxy_buffering off;               # let Suspense/streaming reach the client
  }
}

The proxy terminates TLS and renews certificates, rejects malformed requests, slow-loris connections, and oversized payloads, enforces rate limits, and serves immutable /_next/static files without waking Node — leaving the server to do what it is good at: rendering. Rate limiting lives at the same layer, two lines instead of application code:

limit_req_zone $binary_remote_addr zone=store:10m rate=10r/s;
location /api/ {
  limit_req zone=store burst=20 nodelay;
  proxy_pass http://127.0.0.1:3000;
}

Behind that proxy you need a probe that proves the process is alive, not just that the port is open:

// app/api/health/route.ts
export const dynamic = 'force-dynamic'
export async function GET() {
  try {
    await db.$queryRaw`SELECT 1`
    return Response.json({ ok: true, build: process.env.BUILD_ID ?? 'dev' })
  } catch {
    return Response.json({ ok: false }, { status: 503 })
  }
}

The load balancer polls it, pm2/systemd can use it as a restart trigger, and the build field tells you which deployment answered during a rolling release. Speaking of which: pm2 reload store replaces workers one at a time so no request is dropped, and the drain semantics from the systemd unit apply the same way — SIGTERM in, in-flight requests and after() callbacks out, then exit. Deploying is now "start the new workers, watch the health endpoint, keep the old ones until it turns green", which is the last piece before this system can tell you when something breaks. The proxy also mints X-Request-Id, the thread that Logging and Error Tracking pulls through every layer.

Quiz

1. The container starts and HTML arrives, but the console fills with 404s for /_next/static/chunks/main-*.js. What is missing from the Dockerfile?

Question 1 options

2. A pnpm monorepo image dies at boot with Cannot find module '<repo>/packages/ui/dist/index.js'. Which fix addresses the cause?

Question 2 options

3. During a rolling deploy, server actions intermittently fail with "Failed to find Server Action". Most likely cause?

Question 3 options

4. Why must a reverse proxy sit in front of a self-hosted Next.js server?

Question 4 options

Flashcards

Question

What does `output: 'standalone'` produce, and how do you run it?

Answer

A .next/standalone folder holding server.js, the compiled app, and a pruned node_modules traced from the real import graph — run it with PORT=3000 HOSTNAME=0.0.0.0 node server.js. The next CLI is not included, so next start/dev cannot be used against it.

Question

Which two COPY lines must a Dockerfile add after copying standalone, and what breaks without each?

Answer

.next/static must be copied to ./.next/static or every page 404s its JS/CSS chunks (blank page); public/ must be copied to ./public or favicons, robots.txt, and public images 404. Standalone deliberately excludes both.

Question

`next start` vs standalone: which needs what on disk?

Answer

next start needs the full node_modules and complete .next (image ~1.2 GB); standalone needs only .next/standalone, .next/static, and public (~200 MB) and starts as plain Node, which lets PM2 cluster or systemd run many replicas behind a proxy.

Question

What does the reverse proxy take off the app's plate?

Answer

TLS termination and cert renewal, HTTP/2, rejecting malformed requests/slow connections/oversized payloads, rate limiting, minting X-Request-Id, serving immutable /_next/static from its own cache, and passing streaming responses through (proxy_buffering off).

Question

What must be identical across every replica of a standalone deployment, and why?

Answer

NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (else actions fail to decrypt across instances), BUILD_ID/generated build identity (asset consistency and rollback), and the ISR cache story — a shared cacheHandler with refreshTags, since revalidateTag only hits the instance that called it. Graceful shutdown needs SIGTERM plus a 10–30s drain.

Revision Notes

Key Takeaways

  • 1. standalone output traces the real import graph into server.js plus a pruned node_modules — roughly 200 MB instead of 1.2 GB, and no next CLI inside
  • 2. The two copies people forget are .next/static and public; their absence produces a blank page of 404s and missing static files respectively
  • 3. outputFileTracingRoot is mandatory in a monorepo or shared packages above the app directory never reach the image
  • 4. A reverse proxy owns TLS, abuse filtering, request ids, and static caching so the Node process only renders
  • 5. Multi-replica deployments need identical action encryption keys, a shared ISR cache handler, and a graceful drain window or deploys corrupt in-flight work

Interview Tips

  • • "How would you containerize a Next.js app?" — enable standalone, multi-stage build (deps → builder → runner), copy standalone + static + public, non-root user, secrets at run time; mention the size delta as the reason
  • • "Why is my production image showing a blank page?" — the .next/static copy is missing; walk through what standalone deliberately excludes before guessing at app code
  • • "How do you scale a self-hosted Next.js?" — PM2 cluster or replicas behind a reverse proxy, sticky sessions unnecessary, but shared ISR cache and one encryption key across instances
  • • "What must a reverse proxy do for you?" — TLS, HTTP/2, malformed-request and rate-limit defense, X-Forwarded-Proto/Host headers, immutable static caching, and buffering off so streaming/Suspense reaches the client

Cheat Sheet

Self-Hosting with Standalone Output

Config:

// next.config.mjs
const nextConfig = {
  output: 'standalone',
  outputFileTracingRoot: path.join(__dirname, '../../'), // monorepo root
}

Dockerfile skeleton (runner stage):

FROM base AS runner
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next && chown nextjs:nodejs .next
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 HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

.dockerignore: node_modules, .next, .env* — host modules and secrets must never enter a layer.

Supervision: pm2 start .next/standalone/server.js -i max && pm2 save && pm2 startup · systemd with EnvironmentFile=, Restart=always, TimeoutStopSec=30 (SIGTERM drains in-flight requests).

Multi-instance checklist: shared NEXT_SERVER_ACTIONS_ENCRYPTION_KEY · shared cacheHandler with refreshTags() · generateBuildId pinned · streaming needs proxy_buffering off.

Continue Learning