TL;DR: Use Docker multi-stage builds to separate “build” and “production” stages in your Dockerfile. Dev dependencies and build tools stay out of the final image. In one of my projects, this cut the image size from nearly 2 GB down to under 1 GB.


Why Smaller Images Matter

Keeping your Docker image small has real benefits:

  • Faster deployments — Smaller images mean faster pulls, which speeds up CI/CD pipelines and auto-scaling container startups
  • Lower storage costs — Less registry storage (ECR, GCR, etc.) and less data transfer
  • Reduced security risk — Fewer packages and tools in the image means a smaller attack surface

The Problem: Dev Dependencies Bloat Your Image

When building a Node.js app with Docker, you might write a single-stage Dockerfile like this:

FROM node:24-slim

WORKDIR /app
# Copy dependency definitions first (for layer caching)
COPY package*.json ./
# Install all dependencies including devDependencies
RUN npm ci
COPY . .
# Build the app
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/index.js"]

The problem is that npm ci installs everything — TypeScript, webpack, ESLint, testing libraries, and all the other devDependencies. They all end up in the final image even though they’re not needed at runtime.

The Fix: Multi-Stage Builds

Docker multi-stage builds let you separate the build stage from the production stage.

# Stage 1: Build
FROM node:24-slim AS builder

WORKDIR /app
COPY package*.json ./
# Mount npm cache to speed up rebuilds
RUN --mount=type=cache,id=npm,target=/root/.npm \
    npm ci
COPY . .
RUN npm run build

# Stage 2: Production (fresh image)
FROM node:24-slim

WORKDIR /app
# Copy only the build output from the builder stage
COPY --from=builder /app/dist ./dist
COPY package*.json ./
# Install production dependencies only (no devDependencies)
RUN --mount=type=cache,id=npm,target=/root/.npm \
    npm ci --omit=dev
EXPOSE 3000
CMD ["node", "dist/index.js"]

The idea is simple:

  1. The builder stage installs all dependencies and runs npm run build
  2. The production stage copies only the build output (dist/) and installs production dependencies with npm ci --omit=dev

The builder stage’s node_modules (including devDependencies) never makes it into the final image.

With pnpm

Here’s how it looks with pnpm.

# Stage 1: Build
FROM node:24-slim AS builder

# Enable pnpm via corepack
RUN corepack enable pnpm
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
# Mount pnpm store cache to speed up rebuilds
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build

# Stage 2: Production (fresh image)
FROM node:24-slim

RUN corepack enable pnpm
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
# Install production dependencies only (no devDependencies)
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --prod
# Copy only the build output from the builder stage
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]

Key points:

  • corepack enable pnpm activates pnpm
  • --mount=type=cache mounts the pnpm store as a build cache, skipping re-downloads on rebuilds — great for CI build times too
  • The production stage uses --prod to install only production dependencies

Image Size Comparison

Configuration Image Size
Single-stage ~450 MB
Multi-stage ~180 MB

Approximate values for a small Express + TypeScript app. Your mileage may vary.

In one of my projects, the single-stage image was nearly 2 GB. After switching to multi-stage builds, it dropped to under 1 GB — more than half the original size. The heavier your devDependencies, the bigger the savings.

Summary

Just splitting your Dockerfile into two stages removes unnecessary dev dependencies and dramatically reduces image size. All it takes is adding AS builder and using COPY --from=builder in the next stage — easy to retrofit into existing Dockerfiles.

One thing to watch out for: if you accidentally put a runtime dependency in devDependencies, it’ll work fine in development but break in production. Keep that in mind when splitting your dependencies.


Aside

Seriously though, why do npm packages eat so much disk space? Even a modest project ends up with node_modules casually consuming gigabytes. It’s the ultimate waste — when you’re working on multiple projects, your machine’s disk space just gets chewed up relentlessly.

That’s why I use pnpm, but even then, once it goes into a Docker image the size balloons right back up. I wish build tools at least would slim down. Can it all just… die already?

References