DevOps

Docker Multi-Stage Builds for Next.js 16 Production

11 min readMussawar Hayat

Ship a minimal, secure Next.js 16 App Router image with output: "standalone", multi-stage builds, non-root user, and only the files the runtime needs. Production Dockerfile, .dockerignore, and checklist.

Why Single-Stage Next.js Docker Images Fail in Production

A naive Dockerfile that runs npm install and npm run build in one stage often produces images over 1 GB. They include build tools, the full node_modules, source maps, and cache directories you never need at runtime. Multi-stage builds with Next.js output: "standalone" cut that footprint dramatically while keeping Server Components, Server Actions, and API routes intact.

What You Will Learn

  • How output: "standalone" works and what it traces
  • A production multi-stage Dockerfile for Next.js 16 App Router
  • Correct copy of .next/standalone, .next/static, and public
  • Non-root user, hostname, and security defaults
  • .dockerignore rules that keep the build context small
  • Common mistakes that break CSS, images, or server startup
  • A short production checklist

1. Enable Standalone Output

In next.config.ts (or next.config.js) set:

import type { NextConfig } from 'next'

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

export default nextConfig

When you run next build, Next.js traces the files required to run the server and writes a minimal tree under .next/standalone. That tree includes a generated server.js and only the dependencies the app actually uses. Static assets and the public folder are not fully inlined into standalone, so you must copy them explicitly in the final image stage.

Standalone supports the full App Router feature set: Server Components, Server Actions, Route Handlers, middleware, and ISR. Use static export only when you intentionally drop server features.

2. Production Multi-Stage Dockerfile

Three stages keep install, build, and runtime separate. The final stage never sees your source or full node_modules.

# syntax=docker/dockerfile:1

ARG NODE_VERSION=22-alpine

# --- deps ---
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
RUN if [ -f package-lock.json ]; then npm ci; \
    elif [ -f yarn.lock ]; then corepack enable yarn && yarn install --frozen-lockfile; \
    elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
    else echo "Lockfile required" && exit 1; fi

# --- builder ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN if [ -f package-lock.json ]; then npm run build; \
    elif [ -f yarn.lock ]; then yarn build; \
    elif [ -f pnpm-lock.yaml ]; then pnpm build; \
    else exit 1; fi

# --- runner ---
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

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
CMD ["node", "server.js"]

Key points:

  • HOSTNAME=0.0.0.0 so the process listens on all interfaces inside the container (required for published ports).
  • Copy order: public, then standalone root, then .next/static. Missing the static copy is the most common cause of missing CSS and client bundles.
  • Run as non-root nextjs user.
  • Pin a specific Node major (or digest) in CI rather than floating latest.

3. .dockerignore

Keep the build context small and avoid shipping secrets or local artifacts:

Dockerfile
.dockerignore
node_modules
.next
.git
.gitignore
README.md
.env*
!.env.example
npm-debug.log*
yarn-debug.log*
yarn-error.log*
coverage
.nyc_output
.vscode
.idea
*.md

Do not copy .env files into the image. Pass runtime secrets with environment variables or your orchestrator’s secret store.

4. Build and Run

Use the same pinned image tag as the Dockerfile stages:

docker build -t nextjs-app:prod .
docker run --rm -p 3000:3000 \
  -e DATABASE_URL="..." \
  -e AUTH_SECRET="..." \
  nextjs-app:prod

For Compose, map the same port and inject env vars. Prefer explicit env_file or secrets over baking values into the image.

Image size after a correct multi-stage standalone build is typically a few hundred megabytes instead of a full gigabyte-plus single-stage image, depending on your dependency graph and public assets.

5. Security and Operational Defaults

  • Non-root user — the sample creates a dedicated nextjs user and switches to it before CMD.
  • Read-only root filesystem — where your platform allows, run the container with a read-only root and a writable volume only for paths that must change (for example temporary upload dirs if you use them).
  • No build tools in runtime — the runner stage never installs compilers or the full package manager tree.
  • Healthchecks — add a simple HTTP check against a lightweight route if your orchestrator supports it.
  • Logs — write to stdout/stderr so Docker and your log driver capture application output without file volumes.

6. Common Mistakes

  • Forgetting .next/static — pages load but styles and client JS 404.
  • Forgetting public — images and static files under /public disappear.
  • Listening on localhost only — without HOSTNAME=0.0.0.0 the process is unreachable from outside the container.
  • Copying the whole node_modules into the runner — defeats standalone tracing and bloats the image.
  • Building without a lockfile — non-reproducible installs and surprise dependency versions in CI.
  • Running as root — unnecessary privilege if the process is compromised.

7. Production Checklist

  • output: 'standalone' in Next config
  • Multi-stage Dockerfile: deps → builder → runner
  • Copy public, .next/standalone, and .next/static
  • HOSTNAME=0.0.0.0 and non-root user
  • Strict .dockerignore
  • Secrets only via environment or secret manager
  • Image scanned in CI; Node base image pinned
  • Smoke test: homepage, a Server Action, and a static asset after deploy

Summary

For Next.js 16 App Router on Docker, standalone output plus a three-stage build is the standard production pattern. You keep full server capabilities while shipping only the traced runtime, static chunks, and public assets. The same approach works on a single VPS, Kubernetes, or any container platform.

Key Takeaway

Standalone traces what the server needs. Your Dockerfile’s only job is to copy that tree, static assets, and public files into a minimal non-root image that listens on 0.0.0.0.


Need a hardened Next.js deploy pipeline?

I set up multi-stage Docker builds, VPS Nginx reverse proxies, and CI for production Next.js apps. Get in touch if you want the same stack applied to your project.

Ready to start your project?

Let us discuss how we can transform your digital presence with cutting-edge solutions.