Back to blog

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.

4 min read

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 ci

  • Proper 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/static

  • public

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.

  1. Always check your image size. Don’t assume.

  2. Understand what your framework generates during build.

  3. Multi-stage builds are not optional for production.

  4. “It works” is not the same as “It’s optimized.”

Optimization isn’t about being clever.
It’s about being intentional.