SOROUSH™
MODE

Which Node Image Should You use with Docker?

Docker Images: node:26-slim vs node:26-alpine

Choosing a base image for your Node.js 26 containers looks like a small decision, but it affects image size, build reliability, native module compatibility, and even runtime performance. The two most popular minimal options on Docker Hub are node:<version>-slim and node:<version>-alpine — for Node 26, that means node:26-slim and node:26-alpine. This guide breaks down how they differ and which one you should ship to production.

What is node:26-slim?

The node:<version>-slim tag is a stripped-down Debian-based image. It keeps glibc, the standard C library that virtually all prebuilt Node.js native addons target, but removes compilers, documentation, and most of the packages included in the full node:26 image. The result is an image that behaves exactly like a standard Linux environment while staying reasonably small — typically in the 200 MB range uncompressed, versus roughly 1 GB for the full node:26 image.

Because it is Debian underneath, node:26-slim gives you:

  • glibc compatibility — prebuilt binaries from npm packages (sharp, bcrypt, canvas, Prisma engines, gRPC, SWC, esbuild native builds) work out of the box.
  • apt-get — access to the enormous Debian package repository when you need system libraries such as libvips or openssl tooling.
  • Predictable DNS and threading behavior — the same libc your app was almost certainly developed and tested against.

What is node:26-alpine?

The node:<version>-alpine tag is built on Alpine Linux, a security-oriented distribution built around musl libc and BusyBox. It is the smallest official Node.js image — often 50–80 MB uncompressed — which makes it attractive for fast pulls, small registries, and dense Kubernetes nodes.

The trade-offs come from musl:

  • Native modules may need compiling. Many npm packages ship prebuilt binaries only for glibc. On Alpine you may need apk add python3 make g++ in a build stage, which slows CI and occasionally breaks on version bumps.
  • Subtle runtime differences. musl handles DNS resolution, thread stack sizes, and some locale behavior differently from glibc. Most apps never notice; some (heavy native-addon users, high-concurrency workloads) do.
  • Unofficial support status. Alpine builds of Node.js are community-tier in Node's official build matrix, while Debian builds are top-tier supported platforms.

Head-to-head comparison

Criterianode:26-slim (Debian)node:26-alpine (Alpine)
Uncompressed size~200 MB~50–80 MB
C libraryglibcmusl
Prebuilt native modulesNearly always workSometimes require compilation
Package managerapt-get (huge repo)apk (smaller repo)
Node.js build tierOfficially supportedCommunity / experimental
CVE surfaceSmall, patched quickly by DebianVery small
Best forProduction apps with native depsMinimal microservices, CLIs, sidecars

Which one should you choose?

Default to node:26-slim. For most production applications — APIs, SSR frontends, background workers — the extra ~120–150 MB is irrelevant next to the reliability of glibc. You avoid an entire class of "works locally, crashes in the container" bugs caused by musl incompatibilities, and you stay on Node's officially supported platform tier.

Choose node:26-alpine when image size is a hard requirement: edge deployments, serverless container platforms with cold-start-sensitive pulls, or fleets where registry bandwidth genuinely matters — and when your dependency tree is pure JavaScript or you have verified every native module builds cleanly against musl.

A multi-stage build with slim

Whichever variant you pick, use a multi-stage build so compilers and devDependencies never reach the final image. Here is a complete example where both stages use node:26-slim:

# --- Build stage ---
FROM node:26-slim AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Remove devDependencies before copying to the runtime stage
RUN npm prune --omit=dev

# --- Runtime stage ---
FROM node:26-slim AS runtime
WORKDIR /app

ENV NODE_ENV=production

COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./

USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

Mixing slim and alpine in one build

You can shrink the final image further by building with slim (which handles native-dependency compilation reliably) and serving with alpine:

# --- Build stage: node:26-slim has glibc and reliable build tooling ---
FROM node:26-slim AS build
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Remove devDependencies before copying to the runtime stage
RUN npm prune --omit=dev

# --- Runtime stage: node:26-alpine is much smaller ---
FROM node:26-alpine AS runtime
WORKDIR /app

ENV NODE_ENV=production

COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./

USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

One caveat when mixing the two: slim is Debian-based (glibc) while alpine uses musl. If any of your dependencies include native addons compiled during npm ci, binaries built on slim may not run on alpine. In that case, either use alpine in both stages or run npm ci inside an alpine build stage so the addons are compiled against musl. For pure-JavaScript dependency trees, the mix works fine.

Trimming dependencies with npm prune --omit=dev

npm prune --omit=dev removes every package from node_modules that is only listed in devDependencies — TypeScript, test frameworks, linters, bundlers, and so on. In a multi-stage build this matters because you typically need devDependencies to build (compile TypeScript, run the bundler) but not to run, so the flow is:

  1. npm ci — install everything, including devDependencies.
  2. npm run build — produce the production artifacts.
  3. npm prune --omit=dev — strip node_modules down to runtime dependencies only.
  4. Copy the pruned node_modules into the final stage.

An alternative is to skip pruning and instead run a fresh npm ci --omit=dev in the runtime stage (or in a third stage), which guarantees a clean, lockfile-exact production tree rather than a pruned one. Both approaches end at the same place; prune avoids a second install, while a fresh ci --omit=dev avoids any leftover artifacts from the dev install.

Note that --omit=dev is the modern spelling — --production and npm prune --production still work but are deprecated aliases.

Production and security tips for Node images

  • Set NODE_ENV=production. Many libraries (Express among them) disable debugging paths and enable caching based on this variable. It also makes any stray npm install default to omitting devDependencies.
  • Don't run as root. The official Node images ship a non-root node user — add USER node in the runtime stage and make sure copied files are readable by it (COPY --chown=node:node if needed).
  • Use npm ci, not npm install, in CI and Docker builds. It installs exactly what the lockfile specifies, fails on lockfile drift, and is faster because it deletes and rebuilds node_modules deterministically.
  • Pin your base images. Prefer node:26.2-slim (or a digest, node@sha256:...) over node:latest so builds are reproducible and upgrades are deliberate.
  • Run node directly as PID 1, not through npm start. npm doesn't forward signals, so SIGTERM from the orchestrator never reaches your process and graceful shutdown breaks. Use CMD ["node", "dist/main.js"], and add an init process (docker run --init, or tini in the image) if your app spawns child processes.
  • Handle shutdown signals in your app. Listen for SIGTERM/SIGINT, stop accepting new connections, drain in-flight requests, then exit — otherwise Kubernetes or Docker will hard-kill you after the grace period.
  • Audit and scan regularly. npm audit --omit=dev checks your production dependency tree for known vulnerabilities; container scanners like Trivy, Grype, or Docker Scout catch OS-level CVEs in the base image. Wire both into CI.
  • Keep secrets out of the image. Never COPY .env files or bake tokens into layers (they persist in image history even if deleted later). Use runtime environment variables, Docker/Kubernetes secrets, or BuildKit secret mounts (RUN --mount=type=secret) for build-time credentials.
  • Add a .dockerignore. At minimum exclude node_modules, .git, .env*, logs, and local build output — it speeds up the build context and prevents accidentally shipping local artifacts or secrets.
  • Limit what the process can do. Run the container with a read-only root filesystem where possible (--read-only plus a tmpfs for scratch space), drop Linux capabilities you don't need, and set memory limits so the orchestrator, not the OOM killer, decides what happens under pressure.
  • Consider distroless for the final stage. gcr.io/distroless/nodejs26 contains only Node and your app — no shell, no package manager — which shrinks the attack surface further than alpine, at the cost of harder in-container debugging.

Pinning tags correctly

Never deploy from the bare node:26 moving tag. Pin at least the minor version and variant — node:26.2-slim or node:26.2-alpine3.22 — so rebuilds are reproducible, and pair the tag with a digest in CI for supply-chain safety. The general patterns to remember are node:<version>-slim for the Debian variant and node:<version>-alpine for the Alpine variant, where <version> can be a major (26), minor (26.2), or full patch version.

FAQ

Is node:26-alpine faster than node:26-slim? Not meaningfully. Startup and runtime performance are dominated by Node itself; musl allocator behavior can even be slightly slower under heavy multithreaded native workloads. Alpine wins on pull time, not execution time.

Is Alpine more secure? It has fewer installed packages and therefore fewer CVEs on scanner reports, but Debian slim images are patched promptly and both are solid choices. Running as the non-root node user and keeping dependencies pruned matters more than the distro.

Can I switch between them later? Yes — if your Dockerfile only relies on npm/node and no apt/apk specifics, swapping the FROM line is usually a one-line change. Test native modules carefully when moving to Alpine.

Bottom line: use node:26-slim as your production default, reach for node:26-alpine when every megabyte counts and your dependencies allow it, and always pin your tags.

Cookie-Free by Design. The only cookies we like are the ones that come fresh from the oven.