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:
- The builder stage installs all dependencies and runs
npm run build - The production stage copies only the build output (
dist/) and installs production dependencies withnpm 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 pnpmactivates pnpm--mount=type=cachemounts the pnpm store as a build cache, skipping re-downloads on rebuilds — great for CI build times too- The production stage uses
--prodto 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?