The Day I Realized My Next.js Docker Image Was Way Too Big
I discovered my Next.js Docker image was way bigger than it should be and decided to fix it. In this post, I share how I used standalone output and multi-stage Docker builds to shrink it dramatically, what I learned about shipping only what’s necessary, and why production-ready builds are more than just ‘it works.
I wasn’t even trying to optimize anything.
I was just casually checking the Docker image size of a Next.js project I was working on. Out of curiosity, I ran:
docker image ls
And I had to double-check what I was seeing.

3.7GB For a web application.
At first I thought I built the wrong image. Maybe I tagged something incorrectly. Maybe Docker cache was messing with me.
But no, it was real.
And that’s when I realized something important:
Just because your app works doesn’t mean it’s production-ready.
Why this Is a Problem
On a local machine, 3.7GB might not feel like a big deal. But in production, it absolutely is.
CI/CD pipelines become slower
Image push/pull takes longer
New containers take longer to spin up
Registry storage increases
Security surface grows
It felt wrong shipping something that heavy when the actual app logic wasn’t even that large.
So I started digging.
The Original Dockerfile
Here’s what we were using:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]Honestly? It looks completely fine.
Alpine base image
npm ciProper build step
Nothing screamed “3.7GB problem”.
But the issue wasn’t obvious at first glance.
What Was Actually Happening
The main problem was subtle.
1. We Were Shipping Everything
COPY . .
That single line copies the entire project into the container.
Source code. Config files. Possibly .git. Tests. Everything.
If your .dockerignore isn’t strict, you’re basically packing your whole workspace into production.
2. Full node_modules Was Being Shipped
After running:
RUN npm ci
All dependencies, including devDependencies were installed.
And since we were starting the app with:
CMD ["npm", "start"]
The runtime expected the full project structure and dependency tree.
That means the entire node_modules directory stayed inside the final image.
For a Next.js app, that can be huge.
The Turning Point: Discovering Standalone Output
While researching ways to reduce the size, I came across something in the Next.js docs that I had honestly ignored before:
// next.config.js
module.exports = {
output: "standalone",
};
At first glance, it didn’t seem like a big deal.
But it changed everything.
When you enable standalone output, Next.js generates a special folder:
.next/standalone
This folder contains:
A minimal production server (
server.js)Only the required runtime dependencies
Traced files needed to run the app
Instead of relying on the entire project, it bundles only what’s necessary.
That was the moment it clicked.
We were shipping far more than we needed.
The Second Upgrade: Multi-Stage Docker Build
Standalone output alone isn’t enough.
The next step was restructuring the Dockerfile using multi-stage builds.
Here’s what the optimized version looked like:
FROM node:18-alpine AS base
FROM base AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
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
CMD ["node", "server.js"]
At first it looked more complicated.
But what it does is actually simple:
Build everything in one stage
Throw away the heavy stuff
Copy only what’s necessary into the final image
That’s it.
Why This Version Is So Much Smaller
1. Builder Stage is Thrown Away
The builder stage:
Installs all dependencies
Runs the build
Contains devDependencies
Contains build tools
But none of this exists in the final image.
Only selected artifacts are copied into the runner stage.
2. Only Standalone Output is Copied
Instead of copying the entire project, we only copy:
.next/standalone.next/staticpublic
We do NOT copy:
Full source code
Entire node_modules
Tests
Unused files
This is the biggest difference.
3. Running as Non-Root User
USER nextjs
This doesn’t affect size much, but improves security, making the container production-grade.
The Result
After implementing:
Standalone output
Multi-stage build
Selective copying
The image size dropped from over 3.7GB to around 350MB.

That’s almost a 90% reduction.
And the app behaved exactly the same.
That’s when I realized something important:
Most of the time, large Docker images aren’t because your app is large.
They’re large because you’re shipping things you don’t need.
What I Learned From This
This experience changed how I think about deployments.
Always check your image size. Don’t assume.
Understand what your framework generates during build.
Multi-stage builds are not optional for production.
“It works” is not the same as “It’s optimized.”
Optimization isn’t about being clever.
It’s about being intentional.