TechEarl

How to Dockerize a Next.js App (with output: 'standalone' for Tiny Images)

A Dockerfile for a real Next.js app that ships an image under 200 MB. The standalone output mode, the multi-stage pattern that copies only the runtime files, and the static-assets gotcha most tutorials miss.

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Share thisCopied

Next.js ships a feature called standalone output that's purpose-built for Docker: turn it on in next.config.js and the build produces a self-contained server.js plus only the modules it actually needs. The resulting Docker image is under 200 MB instead of the 1+ GB you'd get by shipping the whole project.

A working Dockerfile

dockerfile
# 1. Dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2. Build
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# 3. Runtime
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1

# Create the non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

# Copy the standalone output + static + public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/public ./public

USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

You also need to opt into standalone output in next.config.js:

javascript
module.exports = {
  output: 'standalone',
};

That's the whole recipe. Build and run:

bash
docker build -t my-next-app .
docker run -d --name my-next-app -p 3000:3000 my-next-app

The image lands around 150-180 MB depending on dependencies. Without standalone output, the same build typically lands at 1-1.5 GB.

What standalone output does

next build normally produces .next/ and expects you to ship the whole project including node_modules. With output: 'standalone', the build instead writes a .next/standalone/ directory that contains:

  • A server.js entrypoint that boots Next without next start.
  • A trimmed node_modules/ with only the modules your app actually requires (tree-shaken from the dependency graph).
  • A minimal package.json.

Everything else — devDependencies, build tools, your original .next/static, your public/ — can be left out of the runtime image. The result is the under-200-MB image.

The three things you have to copy

The standalone directory is not complete on its own. Two things have to be copied separately or your app 404s on assets:

  1. .next/static — the per-build static assets (JS chunks, CSS, optimized images). Copy to ./.next/static.
  2. public/ — your project's public directory (favicon, robots.txt, etc.). Copy to ./public.

The Next.js docs cover this; many tutorials skip it. Without them, your site loads HTML but every CSS file and JS chunk returns 404. The Dockerfile above gets the three copies right.

Pick the right Node image

node:22-alpine everywhere — the build doesn't need glibc (Next.js is pure-JS apart from a handful of optional native modules that work on Alpine), and the runtime image gets the size advantage. If you use Sharp for image optimization, Sharp ships prebuilt musl binaries since v0.30, so Alpine is fine.

If your project has a dependency that breaks on Alpine, switch to node:22-slim in the runtime stage:

dockerfile
FROM node:22-slim AS runtime

Run as non-root

The Dockerfile creates a nextjs user with UID 1001. Why a custom user instead of the image's default node (UID 1000)?

Convention. The Next.js docs use 1001; matching the docs makes the recipe predictable. Either works.

USER nextjs is the line that switches; everything after it runs as the non-root user. The --chown=nextjs:nodejs on the COPY lines ensures the files are owned correctly so the runtime user can read them.

PORT and HOSTNAME

dockerfile
ENV PORT=3000 HOSTNAME=0.0.0.0

The server.js generated by standalone output reads these. HOSTNAME=0.0.0.0 binds on all interfaces (without it, server.js defaults to localhost inside the container and the host can't reach it). PORT=3000 is the standard.

Practical usage: Compose with a database

yaml
services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://postgres:hello@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: hello
      POSTGRES_DB: myapp
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pg-data:

docker compose up --build -d. The Next.js container connects to the db service via its service name.

Static export instead

If your Next.js app is fully static (no API routes, no server components that need a runtime), next export (or output: 'export' in next.config.js) writes plain HTML/CSS/JS to out/, and you can serve it with a static server like Nginx:

dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/out /usr/share/nginx/html
EXPOSE 80

That ships about 25 MB. Only works for fully static sites — no API routes, no SSR, no server components with runtime needs.

Development with hot reload

For dev, you want the source bind-mounted and next dev running:

yaml
# docker-compose.dev.yml
services:
  web:
    image: node:22-alpine
    working_dir: /app
    volumes:
      - .:/app
      - app-node_modules:/app/node_modules
    ports:
      - "3000:3000"
    command: sh -c "npm ci && npm run dev"
    environment:
      NEXT_TELEMETRY_DISABLED: "1"

volumes:
  app-node_modules:

The named volume on node_modules keeps install fast on Mac/Windows; the bind mount on the source enables hot reload. Different file from production — keep them separate.

Common pitfalls

  • output: 'standalone' not enabled in next.config.js. Without it, there's no .next/standalone directory and the build copies fail. Set it.
  • Forgetting to copy .next/static or public/. Site loads HTML, every asset 404s. Both copies are mandatory.
  • HOSTNAME defaulting to localhost. The container's localhost isn't your host's localhost. Set HOSTNAME=0.0.0.0 explicitly.
  • Building Sharp from source on Alpine. Sharp ships prebuilt musl binaries; verify your version is current (v0.30+) and you won't need build tools in the image.
  • Bind-mounting source in production. That's a dev pattern. Production Docker builds copy the source in and ship.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerNext.jsReactDockerfileStandaloneDevOps

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

H

How to Dockerize a Node.js App

A Dockerfile for a real Node.js app: multi-stage build, npm ci for deterministic dependencies, the node_modules-volume trick that makes bind-mounted source fast on Mac, and the non-root user that most tutorials skip.