
How to Reduce Docker Image Size for Node.js Apps
Mahmud Hasan
October 11, 2026
Step 0: Weigh the image before you diet it
Run docker images your-app and look at the actual number. Most Node.js apps start somewhere between 1 and 1.2GB with a naive Dockerfile — the full node:20 base alone is about 1.1GB before your code even enters the picture. Then run docker history your-app to see which layer is the heavy one, or dive for a layer-by-layer x-ray. One team found their node_modules folder alone was eating 191MB — a detail the top-line number never shows. That's your baseline: measure again after every step below, because guessing which layer matters is how people spend an afternoon on the wrong one.
Step 1: Swap the base image
One line, the biggest ROI change you'll make. The official Node.js images come in several weights, and the gap between the heaviest and lightest is roughly an order of magnitude (figures from the devops-daily reference, December 2025 — tags drift a little over time):
node:20— ~1.1GBnode:20-slim— ~240MBnode:20-alpine— ~140MBgcr.io/distroless/nodejs20— ~120MB
FROM node:20 to FROM node:20-alpine deletes roughly 950MB. Alpine is the sweet spot for most teams: small, maintained, still has a package manager. -slim is the safer pick if Alpine's musl libc worries you (there's a catch — see below). Distroless is the lightest option, but it ships no shell, which changes how you debug a live container. And pin your tag: node:20-alpine works, node:20.11.0-alpine3.19 is better, a digest pin is best — tags get retagged upstream without telling you.
Step 2: Split the build into stages
The one structural change to make. A multi-stage build uses a fat image for compiling and a lean one for running, and only the artifacts cross over:
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: production
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]
The build stage has the TypeScript compiler, devDependencies, and test fixtures. The final image has none of that — just the runtime, production dependencies, and your compiled output. The devops-daily walkthrough takes this exact pattern from 1.2GB down to 180MB (85%), and their fully-optimized version (adding the steps below) lands at 45MB. The rule of the pattern: nothing installed in the builder stage exists in the final image unless you explicitly COPY --from=builder it there.
Step 3: Install only what runs in production
Use npm ci instead of npm install — it's deterministic, fails on lockfile mismatch instead of silently drifting, and it's the command built for automation. Then install production dependencies only, and clean the cache in the same layer:
RUN npm ci --only=production && npm cache clean --force
That cache-clean matters: npm keeps a download cache in the image that your running app never touches, and cleaning it in the same RUN keeps those bytes out of the layer entirely. (Yarn: yarn install --frozen-lockfile --production && yarn cache clean.) The common mistake is installing everything in the builder and copying the whole node_modules into production — that drags test runners, type definitions, and linters into every deploy.
Step 4: Fix the COPY order and write a real .dockerignore
Docker caches each layer and invalidates everything below the first changed instruction. So: copy what changes least first.
COPY package*.json ./
RUN npm ci --only=production
COPY . .
With this order, editing a source file rebuilds only the last layer — the dependency layer survives. The anti-pattern is COPY . . before RUN npm install: every code change then re-runs the full install, so builds are slow and images are big.
Then add a .dockerignore that actually lists things:
node_modules
.git
.gitignore
.env
.env.local
dist
build
coverage
*.test.js
*.log
README.md
docs/
.vscode
.DS_Store
This keeps node_modules, git history, and environment files out of the build context entirely — and it keeps secrets like .env from being baked into a layer you might push to a registry.
Step 5: Merge RUN commands and clean up in the same layer
Here's the fact that surprises people: deleting a file in a later Docker layer does not make the image smaller. Layers are append-only diffs — rm in layer 12 only records "this file is gone," while the bytes from layer 4 still ship. Cleanup must happen in the same RUN that created the mess:
RUN apt-get update && \
apt-get install -y --no-install-recommends dumb-init && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
On Alpine it's simpler: apk add --no-cache never writes a cache. The same-layer rule is also why the official nodejs/docker-node guidance compiles native modules as apk add --no-cache --virtual .gyp python3 make g++ && npm install && apk del .gyp — toolchain in, build, toolchain out, one layer.
Step 6: The last mile — strip the package manager, or bundle to one file
After steps 1–5 you're probably under 200MB. The remaining weight is the Node.js distribution plus npm. The official docker-node guidance shows the aggressive route: copy just /usr/local/bin/node (plus node_modules/dist) onto a bare Alpine image — no npm, no yarn. The most aggressive variant is bundling the whole app to a single file with esbuild (npx esbuild ./src/index.ts --bundle --platform=node --outfile=build/index.js) — one walkthrough measured roughly a 3x reduction over their already-multi-stage baseline. The honest caveat from that same walkthrough: a bundled file produces unreadable stack traces, and source maps give back some of the size you saved. For most teams, distroless after step 5 is the better trade — the last 50MB isn't worth losing debuggability.
The Alpine catch: native modules
Alpine uses musl libc instead of glibc, and packages with native bindings — bcrypt, sharp, better-sqlite3 — sometimes refuse to build or misbehave on it. Test before you commit: run your suite against the container and watch for segfaults in exactly those packages. If a native module won't cooperate, keep the toolchain in the builder stage only (that usually just works), or fall back to node:20-slim — Debian-based, glibc, still ~240MB. What you should not do is add python3 make g++ to the final image. That undoes the whole exercise.
Your checklist, in order
Apply these in order, measuring after each — the early steps carry almost all the savings:
- Measure.
docker images,docker history, and dive for the detail. Write the number down. - Swap the base.
node:20→node:20-alpine(or-slimif musl scares you). Pin the tag. - Go multi-stage. Build tools in the builder, only artifacts in the final image.
- Production deps only.
npm ci --only=productionplusnpm cache clean --force, same layer. - Fix COPY order + .dockerignore. Deps before source; keep git, env files, and node_modules out of the context.
- Merge RUNs, clean in-layer.
apk add --no-cache; apt installs end with index cleanup. - Decide on the last mile. Distroless or single-file bundling — only if you still care after step 6.
The realistic outcome for a typical Express or Fastify app: 1.1GB down to somewhere between 45 and 180MB. Smaller images pull faster, deploy faster, cost less to store, and give attackers less to work with. The official Docker multi-stage docs are the best next read if any step here felt rushed.
And the one time not to bother: if the image only runs on your own laptop, nobody pulls it, and a gigabyte of disk is meaningless to you. Optimization has a maintenance cost — debugging a distroless container at 2am is genuinely worse — so spend it where the pulls happen: CI runners, registries, and production deploys.
References
- devops-daily — Docker Image Optimization: Best Practices (base-image size table, multi-stage 1.2GB→180MB, full optimization 1.2GB→45MB, .dockerignore recipe, dive/docker-slim tooling)
- nodejs/docker-node — BestPractices.md (node-gyp on Alpine toolchain pattern, stripping npm/yarn from the final image, Node ≥26 Yarn removal)
- tericcabrel.com — Build the Docker image of a Node.js application (Dive analysis: node_modules 191MB; esbuild single-file bundling ~3x smaller; stack-trace caveat)
- dev.to — Shrinking a Node.js Docker Image from 2.5GB to 300MB (pkg single-binary approach: 2.5GB→300MB, deploy 20min→7min)
- Docker official docs — Multi-stage builds
Related on Byte by Mahmud: How to Make Laravel Faster: Six Fixes in the Order That Actually Matters — the same fix-in-order method, applied to PHP; and Node.js Is Slowing Down on Purpose — why the runtime you're containerizing ships the way it does.
Comments
More in Web Development

In March, Google's AI Tanked Unity's Stock 22%. This Week They're Building Games Together.
Google's Playground turns text prompts into playable browser games, and Unity's Spark will add the professional tier — a direct shot at Roblox from the two companies that fought over AI gaming six months ago.
Read more
Is macOS Still a Certified UNIX? What the Registry Actually Says
HN noticed Apple missing from The Open Group's UNIX registry overview — but the register's own detail pages still list macOS Tahoe as certified. Here's what the badge actually means, and why your Mac never ran the version that passed the test.
Read more
The AI Boom's Favorite Number Just Dropped From $70B to $50B. Wall Street Noticed.
The FT says OpenAI's annualized revenue is $50 billion, not the $70 billion investors were using. Wall Street sold tech on the difference — here's why one disputed number can sink trillions, and what to watch instead of model launches.
Read more