Distroless & Alpine Trade-offs
Choose a container base image based on libc compatibility, image size, and debuggability - not habit.
Recipe
Quick-reference recipe card - copy-paste ready.
# Default: Debian slim (glibc) - best npm compatibility
FROM node:24-bookworm-slim AS prod
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY dist ./dist
USER node
CMD ["node", "dist/main.js"]When to reach for this: Greenfield Node APIs with no native modules that require musl-specific builds. This is the team default.
Working Example
Three final-stage options for the same Express API:
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json src ./
RUN npm run build
# Option A: bookworm-slim (recommended default)
FROM node:24-bookworm-slim AS slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]
# Option B: Alpine (smaller, musl)
FROM node:24-alpine AS alpine
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]
# Option C: Distroless (minimal, no shell)
FROM gcr.io/distroless/nodejs24-debian12 AS distroless
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=slim /app/node_modules ./node_modules
USER nonroot
CMD ["dist/main.js"]docker build --target slim -t api:slim .
docker build --target alpine -t api:alpine .
docker build --target distroless -t api:distroless .What this demonstrates:
- Same compiled
dist/across all three runtimes - Alpine may need
libc6-compatfor some prebuilt binaries - Distroless copies
node_modulesfrom a glibc build stage (never compile inside distroless)
Deep Dive
libc: glibc vs musl
| Base | libc | Typical size | npm native modules |
|---|---|---|---|
bookworm-slim | glibc | ~180 MB | Best compatibility |
alpine | musl | ~120 MB | Frequent rebuild issues (sharp, bcrypt, prisma) |
distroless/nodejs24 | glibc | ~130 MB | Good if built on glibc stage |
Node itself ships prebuilt binaries for glibc Linux. Alpine often forces npm rebuild or source compiles.
When Alpine Works
- Pure JavaScript dependencies only
- You control native deps and test
npm cion Alpine in CI - Image size at registry matters (many edge pulls)
When Distroless Works
- Production API with no on-container debugging
- Security policy forbids shells and package managers in runtime
- You debug via logs, metrics, and ephemeral debug pods (K8s)
Debugging Trade-off
# bookworm-slim: shell available
docker run -it --entrypoint bash api:slim
# distroless: no shell - use debug pod or copy core dumps off-box
kubectl debug pod/api-xyz -it --image=busyboxGotchas
sharpon Alpine - common failure. Fix: use bookworm-slim or officialsharpAlpine install docs with pinned versions.- Building on Mac, running Alpine - architecture and libc mismatch. Fix: always
docker buildon Linux CI target. prisma generatein wrong stage - wrong OpenSSL bindings. Fix: generate in glibc build stage; copy artifacts.- Distroless without
USER nonroot- runs as root by default on some tags. Fix: explicitUSER nonroot. - Assuming smaller always means faster - musl allocator differs; benchmark your workload. Fix: load test before switching.
- Alpine
apkin production Dockerfile - bloats layers. Fix: multi-stage; runtime has no package manager.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| bookworm-slim | Default for Node APIs | You need smallest possible image and have tested Alpine |
| Alpine | Size-sensitive edge deploys, pure JS stack | Heavy native module usage |
| Distroless | Hardened prod, K8s with debug tooling elsewhere | Team relies on docker exec bash |
| Chainguard Node images | Supply-chain hardened bases | You cannot adopt new base image cadence |
FAQs
What is our team default?
node:24-bookworm-slim unless a measured need (size, security scanner mandate) justifies Alpine or distroless.
How do I test Alpine compatibility in CI?
Add a matrix job: docker build --target alpine and run smoke tests. Fail the PR if native modules break.
Can I use distroless with Fastify or NestJS?
Yes. Build on bookworm-slim, copy dist/ and node_modules into distroless. No code changes required.
Does distroless include npm?
No. All installs happen in an earlier stage. The runtime only runs node.
Alpine vs slim size difference?
Often 40-80 MB saved, depending on node_modules. Measure with docker images after production npm ci.
What about node:24-alpine3.20 pinning?
Pin minor Alpine versions in regulated environments. Track Node Docker release notes for base image updates.
Related
- Multi-Stage Builds - build on full image, run on minimal
- Image Slimming - size without musl risk
- Non-Root Containers -
USER node/nonroot - Docker Basics - first Dockerfile
- Docker Best Practices - section checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.