Your Next.js Docker Image Is Probably 1GB. Here's How to Fix It.
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 bu
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/staticfor 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:1line 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 π
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.