Pages

Aug 25, 2026

Optimizing Alpine Node.js Images Under 50MB.

    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
  • devDependencies from 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.

dockerfile
# ---- 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"

Only two things cross the boundary between stages: the compiled dist/ output and the
pruned node_modules.
Everything else — TypeScript, ESLint, Jest, the
src/ folder, build caches — stays behind in the builder stage
and is discarded.

Step 3: Trim node_modules Aggressively

Even production dependencies carry weight you don't need at runtime:

dockerfile
RUN 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.ts or config files at runtime.
Run your test suite against the pruned image before shipping this in CI.

Two tools worth knowing:

  • npm ci instead of npm install — faster, deterministic, and doesn't touch package-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.5MB

Step 4: Use .dockerignore Like It's Load-Bearing

Docker'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 .env files 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-init is a tiny init process — worth the ~1MB so your app correctly handles SIGTERM for graceful shutdowns instead of
Node ignoring it as PID 1.)

Results You Can Expect

Optimization stepApprox. 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 in puppeteer or sharp won'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, not node:20
  • Multi-stage build separates builder and runtime
  • npm ci --omit=dev (or npm prune --omit=dev) before the final copy
  • npm cache clean --force after install
  • .dockerignore excludes .git, node_modules, tests, .env*
  • Container runs as non-root USER node
  • Native modules tested against musl, not just glibc
  • Image inspected with dive before shipping

Get 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