Optimizing Alpine Node.js Images Under 50MB.
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
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.