linux

Optimizing Alpine Node.js Images Under 50MB.

Optimizing Alpine Node.js Images Under 50MB.

By Shubhankar Tripathi • • 5 min read

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 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 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.