A production Node.js image that ships at 900MB isn't just wasteful — it's slower to build, slower to deploy, slower to scan for vulnerabilities, and it drags out every rollout and autoscale event. With the right build strategy, you can get a real Node.js service image comfortably under 50MB, sometimes under 30MB, without giving up npm, native modules, or a normal Node runtime.
This post walks through why default Node images are so bloated, and the concrete techniques — multi-stage builds, Alpine's musl-based minimalism, dependency pruning, and a few sharp edges to watch for — that get you there.
Why Default Node Images Are So Big
A typical node:20 image built on Debian weighs in around 900MB–1.1GB. Most of that is dead weight for a running service:
- A full Debian userland (apt, glibc, shells, man pages, package caches)
- Build toolchains (gcc, python, make) needed only to compile native addons — not to run the app
devDependenciesfrom npm that exist purely for testing, linting, and bundling- npm's own cache and metadata left behind after
npm install - Source maps, test files, markdown docs, and example folders bundled inside
node_modules
None of this is needed once your app is actually running. The fix is to separate build-time needs from run-time needs, and to swap the base OS for something smaller.
Step 1: Start From Alpine, Not Debian
Alpine Linux uses musl libc instead of glibc and BusyBox instead of GNU coreutils, which alone shrinks the base OS from ~120MB to about 5MB.
FROM node:20-alpine
This one swap typically takes a Node image from ~950MB down to somewhere in the 150–250MB range, depending on your dependencies — before you've done anything else.
The catch: musl isn't glibc. Some native npm modules (things that compile C/C++ bindings, like older versions of bcrypt, sharp, or sqlite3) can behave differently or fail to run pre-built binaries meant for glibc. Usually this is solved by installing the module's Alpine build dependencies (python3, make, g++) at build time, or by using packages that ship musl-compatible prebuilds. Test your specific dependency tree on Alpine before committing to it in production.
Step 2: Multi-Stage Builds — The Real Lever
This is where most of the size actually disappears. A multi-stage build compiles and installs everything in a throwaway builder stage, then copies only the finished artifacts into a clean, minimal runtime stage. The compiler, dev dependencies, and source files never make it into the final image.
# ---- Stage 1: builder ---- FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # strip devDependencies before copying forward RUN npm prune --omit=dev # ---- Stage 2: runtime ---- FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/package.json ./package.json USER node EXPOSE 3000 CMD ["node", "dist/index.js"pruned
Only two things cross the boundary between stages: the compileddist/output and thenode_modules.
Everything else — TypeScript, ESLint, Jest, thesrc/folder, build caches — stays behind in the builder stage
and is discarded.Step 3: Trim
node_modulesAggressivelyEven production dependencies carry weight you don't need at runtime:
dockerfileRUN npm ci --omit=dev \ && npm cache clean --force # remove common dead weight left inside node_modules RUN find node_modules -type f \( \ -name "*.md" -o \ -name "*.markdown" -o \ -name "LICENSE*" -o \ -name "*.map" -o \ -name "*.ts" \ \) -delete \ && find node_modules -type d \( \ -name "test" -o \ -name "tests" -o \ -name "__tests__" -o \ -name ".github" \ \) -exec rm -rf {} +Be careful with blanket deletion rules — some packages genuinely need their
.d.tsor config files at runtime.
Run your test suite against the pruned image before shipping this in CI.Two tools worth knowing:
npm ciinstead ofnpm install— faster, deterministic, and doesn't touchpackage-lock.json.dive(a CLI tool) — lets you inspect exactly which layer added which bytes, so you're not guessing.$ dive myapp:latest Layers Size Command ──────────────────────────────── Layer 1 5.2MB FROM alpine Layer 2 1.1MB RUN apk add --no-cache libc6-compat Layer 3 28.4MB COPY node_modules Layer 4 3.8MB COPY dist ──────────────────────────────── Total 38.5MBStep 4: Use
.dockerignoreLike It's Load-BearingDocker's build context gets sent to the daemon before any instruction runs, and without a
.dockerignore, you're routinely shipping.git,node_modules, test fixtures,and.envfiles into that context — some of which can leak into layers by accident..git node_modules npm-debug.log Dockerfile .dockerignore .env* coverage *.md .vscode tests/
This doesn't shrink the final image directly (multi-stage builds already isolate that), but it speeds up
builds and closes an easy path for secrets or bloat to sneak in.Step 5: Distroless or Alpine — Know the Trade-off
For teams chasing the absolute smallest, most attack-surface-minimal image, Google's gcr.io/distroless/nodejs20 is worth knowing about. It strips out even the shell
and package manager — there's no sh, no apk, nothing to exec into for debugging.
Alpine hits the sweet spot for most teams: small, but still debuggable in an incident. Distroless is worth it once shrinking
attack surface matters more than being able to shell in — pair it with a separate debug image for troubleshooting rather than
debugging in prod.Putting It Together: A Complete Example:
dockerfile# ---- builder ---- FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build && npm prune --omit=dev # ---- runtime ---- FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production RUN apk add --no-cache dumb-init COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/package.json ./package.json USER node EXPOSE 3000 ENTRYPOINT ["dumb-init", "--"] CMD ["node", "dist/index.js"]
(dumb-initis a tiny init process — worth the ~1MB so your app correctly handlesSIGTERMfor graceful shutdowns instead of
Node ignoring it as PID 1.)Results You Can Expect
Optimization step Approx. image size node:20(Debian, no optimization)~950MB Switch to node:20-alpine~180MB + Multi-stage build ~70MB + Prune devDependencies + npm cache ~45MB + Strip docs/maps/tests from node_modules~35MB Real numbers vary with your dependency tree — a service pulling inpuppeteerorsharpwon't hit 35MB no matter what you do, because
the underlying binaries are genuinely large.But for a typical Express or Fastify API with a modest dependency list, sub-50MB is a realistic, repeatable target.Quick Checklist
- Base image is
node:20-alpine, notnode:20- Multi-stage build separates builder and runtime
npm ci --omit=dev(ornpm prune --omit=dev) before the final copynpm cache clean --forceafter install.dockerignoreexcludes.git,node_modules, tests,.env*- Container runs as non-root
USER node- Native modules tested against musl, not just glibc
- Image inspected with
divebefore shippingGet these right and the image itself stops being something you have to think about — it just gets out of the way.
No comments:
Post a Comment