Next.js Dockerfile with Standalone Output (Alpine, Non-Root)

This Next.js Dockerfile uses three stages: install dependencies, build the app, and run the self-contained standalone server on node:24-alpine. Only the traced files Next.js needs at runtime are copied, so node_modules from the build never ships. The server runs as the built-in node user on port 3000.

CI status
Build passing
Image size (uncompressed)
210.4 MB
Build time
45 s
Runtime image
node:24-alpine
Port
3000
Last built
2026-10-15
Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:24-alpine 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-alpine 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-alpine AS deps RUN apk add --no-cache libc6-compat

    The deps stage installs packages. libc6-compat helps native modules built for glibc run on Alpine's musl libc.

  2. RUN npm ci

    Installs exactly what the lockfile specifies. Because only package.json and package-lock.json were copied first, this layer is cached until dependencies change.

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

    The builder stage reuses the installed modules instead of installing them again, then copies the source and runs the production build.

  4. ENV NEXT_TELEMETRY_DISABLED=1

    Turns off anonymous Next.js telemetry during the build. The runner stage sets it again for the server process.

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

    HOSTNAME=0.0.0.0 is the important one. Without it the standalone server listens only on localhost inside the container and the published port appears dead.

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

    The standalone folder holds server.js plus the minimal node_modules Next.js traced. --chown gives the node user ownership without an extra layer.

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

    Static chunks are not included in the standalone output, so they are copied separately. Skipping this line produces pages without CSS or JavaScript.

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

    Switches to the unprivileged user that ships with the Node image and starts the standalone server directly with node, without npm in between.

Build and run

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

Build from the project root, then open http://localhost:3000. Public environment variables prefixed with NEXT_PUBLIC_ are inlined at build time, so pass those as build arguments, not at docker run.

Other Next.js Dockerfile variants

Standalone output on Alpine is the leanest common way to self-host Next.js and is the pattern the Next.js team documents. Variants for pnpm, Bun or a Debian slim base suit teams with those toolchains or with native dependencies that dislike musl. They appear below once verified.

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
The build did not produce standalone output. Add output: 'standalone' to next.config, rebuild, and confirm .next/standalone exists locally after npm run build.
The page loads but has no styling and the browser reports 404s for /_next/static files
The .next/static folder was not copied into the runner stage. Keep that COPY line, and check that .dockerignore is not excluding it before the build runs.
The container starts but nothing answers on the mapped port
The server is probably bound to localhost. Make sure HOSTNAME=0.0.0.0 is set in the runner stage and that you publish the same port the PORT variable uses.

FAQ

Why are there three stages instead of two?
Separating dependency installation from the build means a code-only change reuses the cached deps layer. The final stage then copies only runtime output, so build tooling never ships.
Do I need sharp for image optimization?
Recent Next.js versions use sharp automatically when it is installed. If next/image is slow or warns in production, add sharp as a dependency and rebuild the image.
Can I use this with the App Router and Server Actions?
Yes. Standalone output supports both routers. Runtime secrets used by server code should be passed as environment variables when the container starts.