Next.js Dockerfile on Debian Slim (Standalone, Non-Root)

This Next.js Dockerfile builds standalone output in three stages on node:24-slim, a Debian-based image with glibc, and runs the self-contained server as the node user on port 3000. Pick it over Alpine when your app uses native modules or prebuilt binaries compiled for glibc, such as image processing, Prisma engines or headless browsers.

CI status
Build passing
Image size (uncompressed)
268.6 MB
Build time
46 s
Runtime image
node:24-slim
Port
3000
Last built
2026-10-15
Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:24-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
.dockerignore
.git
node_modules/
.next/
.env*
npm-debug.log*
Dockerfile
.dockerignore

Before you build

How this Dockerfile works

  1. FROM node:24-slim AS deps

    The slim image is Debian with unnecessary packages removed. It uses glibc, so native modules built for standard Linux work without the compatibility shims Alpine needs.

  2. COPY package.json package-lock.json ./ RUN npm ci

    Installs exactly what the lockfile pins. Copying only the manifests first keeps this layer cached until dependencies change.

  3. COPY --from=deps /app/node_modules ./node_modules

    The builder stage reuses the installed modules, then copies the source and runs the production build.

  4. ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0

    HOSTNAME=0.0.0.0 makes the standalone server listen on all interfaces. Without it, it binds to localhost and the published port looks dead.

  5. COPY --from=builder --chown=node:node /app/.next/standalone ./

    Copies server.js and the minimal node_modules Next.js traced during the build, owned by the unprivileged node user.

  6. COPY --from=builder --chown=node:node /app/.next/static ./.next/static

    Static chunks aren't part of the standalone folder. Leaving this out serves pages without their CSS and JavaScript.

  7. USER node EXPOSE 3000 CMD ["node", "server.js"]

    Runs the server as the built-in node user and starts it with node directly, so shutdown signals reach the process.

Build and run

terminal
docker build -t nextjs-app .
docker run --rm -p 3000:3000 nextjs-app

Build from the project root and open http://localhost:3000. Remember that NEXT_PUBLIC_ variables are baked in at build time, so pass them as build arguments rather than at docker run.

Other Next.js Dockerfile variants

Alpine produces the smaller image and suits most apps. The Debian slim variant costs some extra megabytes but avoids musl compatibility problems with native dependencies. Compare the measured image sizes of both in the table below before choosing.

VariantRuntime imageImage sizeCI
Node Alpine + standalone outputnode:24-alpine210.4 MBPassing
Node Debian slim + standalone outputnode:24-slim268.6 MBPassing

Troubleshooting

COPY failed: /app/.next/standalone: not found
Standalone output isn't enabled. Add output: 'standalone' to next.config and confirm the folder appears locally after npm run build.
Pages render without styles and /_next/static requests return 404
The .next/static folder wasn't copied into the runner stage. Keep that COPY line and check .dockerignore doesn't exclude it before the build.
A native module fails to load with an error about a missing shared library
The module needs a system library that slim omits. Install it with apt-get in the runner stage, then clean the apt lists in the same layer.

FAQ

Should I use Alpine or slim for Next.js?
Start with Alpine for the smaller image. Switch to slim if a dependency ships glibc-only binaries, crashes on musl, or needs libc6-compat workarounds that keep breaking.
Why three stages instead of two?
Separating dependency installation from the build lets code-only changes reuse the cached install. The final stage then copies only runtime output, so build tools never ship.
How do I pass runtime secrets?
Server-side code reads environment variables when the container starts, so pass secrets with -e or an env file at docker run. Never bake them into the image.