You already know what Docker is. You've written a Dockerfile, run docker build, and shipped something.

But if your Next.js Dockerfile looks like this:

FROM node:20 WORKDIR /app COPY . . RUN npm install RUN npm run build CMD ["npm", "start"] 

…then your image is carrying a lot of things production never needs:

  • The full Debian-based Node image
  • All devDependencies (TypeScript, ESLint, testing libraries, etc.)
  • Your entire source code, including stuff that's already been compiled
  • The npm cache
  • And it's running as root

An image like this can easily cross 1 GB. That means slower CI pipelines, slower deploys, higher registry storage costs, and a bigger attack surface.

Let's fix it step by step.

Step 1: Turn on Next.js Standalone Output

This is the single biggest win, and most people don't know it exists.

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

/** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", }; module.exports = nextConfig; 

With output: "standalone", next build traces which files your app actually imports at runtime and copies only those into .next/standalone — including a minimal node_modules and a ready-to-run server.js.

So instead of shipping your whole node_modules folder, you ship only what the server really needs.

⚠️ Standalone output does not copy public/ or .next/static for you. We'll handle that in the Dockerfile.

Step 2: Use a Multi-Stage Build

A multi-stage build lets you use one stage to build your app (with all the heavy tooling) and a separate, clean stage to run it. Only what you explicitly copy makes it into the final image.

# syntax=docker/dockerfile:1 # ---------- Stage 1: Install dependencies ---------- FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci # ---------- Stage 2: Build the app ---------- FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build # ---------- Stage 3: Production runner ---------- FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 # Create a non-root user RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs # Copy only what's needed to run 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 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" CMD ["node", "server.js"] 

What's happening here

Stage Purpose Ends up in final image?
deps Installs all dependencies ❌ No
builder Runs next build with full tooling ❌ No
runner Runs the compiled standalone server ✅ Yes

TypeScript, ESLint, your raw source files, the npm cache — all of that stays behind in the earlier stages and gets thrown away.

Step 3: Cache Mounts — Stop Re-downloading the Internet

Notice this line:

RUN --mount=type=cache,target=/root/.npm \ npm ci 

This is a BuildKit cache mount. It keeps npm's download cache between builds on your machine (or CI runner), but never writes it into the image.

The result: when you change one dependency, npm doesn't have to re-download every package from scratch. And your image doesn't carry the cache around either.

💡 BuildKit is the default builder in modern Docker versions, so this works out of the box. The # syntax=docker/dockerfile:1 line at the top ensures you get the latest Dockerfile features.

Step 4: Don't Run as Root

By default, containers run as root. If someone ever finds a vulnerability in your app, they'd have root inside the container.

These lines fix that:

RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs USER nextjs 

It's a two-line change that closes off an entire class of problems. There's no reason not to do it.

Step 5: Add a Proper .dockerignore

Without this, COPY . . sends your local node_modules, .next, and .git folders into the build context — slowing builds and potentially overwriting the clean dependencies Docker just installed.

node_modules .next .git .env* npm-debug.log Dockerfile .dockerignore README.md 

Also note .env* — you never want local secrets baked into an image.

Check the Difference Yourself

Build both versions and compare:

docker build -t next-naive -f Dockerfile.naive . docker build -t next-optimized . docker images | grep next- 

The exact numbers depend on your app and dependencies, but the optimized image will be a fraction of the naive one's size.

Bonus: Fast Local Development with Compose Watch

Production images are great, but what about local dev? Rebuilding the image every time you edit a component would be painful.

The old approach was bind-mounting your source folder into the container — which often caused slow file watching (especially on macOS/Windows) and node_modules conflicts between host and container.

Docker Compose Watch solves this. It watches your files and syncs changes into the running container, and can rebuild automatically when something like package.json changes.

compose.yaml

services: web: build: context: . target: builder # use the build stage, which has dev tooling  command: npm run dev ports: - "3000:3000" develop: watch: # Sync source changes instantly → Next.js hot reload picks them up  - action: sync path: ./src target: /app/src - action: sync path: ./public target: /app/public # Rebuild the image when dependencies change  - action: rebuild path: package.json 

Start it with:

docker compose watch 

Now:

  • Edit a file in src/ → it's synced into the container → hot reload kicks in
  • Change package.json → Compose rebuilds the image automatically

Notice the target: builder line — we're reusing the same Dockerfile for development by stopping at the builder stage, which still has all the dev dependencies. One Dockerfile, two use cases.

Quick Recap

Problem Fix
Huge node_modules in the image output: "standalone"
Build tools shipped to production Multi-stage builds
Slow, repeated dependency installs BuildKit cache mounts
Container running as root Non-root USER
Junk and secrets in build context .dockerignore
Painful local dev in containers Docker Compose Watch

None of these are hard on their own, but together they turn a bloated, slow, root-running image into something lean and production-ready.

Have you tried any of these in your own projects? Or is there a Docker trick you swear by that I didn't cover? Let me know in the comments 👇