Node.js 79 🟢 Multi-Stage Docker Builds, Image Minimization, and Non-Root Execution
The previous chapter covered the four decisions that shape a production Node.js image. This chapter goes deeper into the two that have the largest measurable impact: the multi-stage build structure that determines what ships, and the non-root execution model that determines what happens when something goes wrong. The size difference is not marginal. A single-stage build using the full node:22 image lands around 1.2 GB; the same application with a multi-stage build and a -slim runtime stage lands around 240 MB . That is an 80% reduction, and the reduction is not just disk space—it is the elimination of the TypeScript compiler, the test framework, the linter, and every transitive dev dependency from the runtime attack surface.
The non-root decision is equally consequential but for a different reason. Running as root inside a container does not grant root on the host under normal Docker configurations, but it removes a layer of defense. A container escape, a misconfigured volume mount, or a compromised dependency that writes to the filesystem is meaningfully worse if the process had root privileges . The official Node.js images ship a node user with UID 1000 specifically for this purpose, and switching to it is a single USER node instruction. Kubernetes’ restricted Pod Security Standard enforces runAsNonRoot, which means a container running as root will not schedule in a properly configured production cluster .
Key point: Multi-stage builds separate the build toolchain from the runtime image, and the final stage should copy only compiled artifacts and production dependencies; the runtime stage must switch to the non-root node user, and the image size reduction from these two changes is typically 80% or more.
Why the multi-stage structure is the largest single win
The dev dependency weight problem. A typical TypeScript Node.js project’s devDependencies—TypeScript, Jest, ESLint, Webpack, and their transitive trees—routinely outweigh the production dependencies several times over . A naive COPY . . followed by npm install bakes all of it into the final image. The compiler that transpiles TypeScript is not needed to run the transpiled JavaScript. The test framework that verifies the code is not needed to serve requests. The linter that checks style is not needed at runtime. Each of these is pure attack surface with zero runtime benefit.
The structural separation. The multi-stage Dockerfile has three logical stages: a build stage with full tooling that runs npm ci and npm run build, a production-dependencies stage that runs npm ci --omit=dev, and a runtime stage that copies the compiled output and the production node_modules from the previous stages. The build stage is discarded after the artifacts are copied. The final image never contains the TypeScript compiler, the test suite, or the source maps .
The layer caching interaction. The order of COPY instructions determines how much of the build is cached. Copying package.json and package-lock.json before the source code, and running npm ci before COPY . ., means that changing a source file does not invalidate the dependency installation layer. The build reuses the cached node_modules and only re-runs the source copy and compile steps . This is the difference between a 30-second rebuild and a 3-minute rebuild during development, and it compounds across a CI pipeline.
The npm ci requirement. npm ci requires a package-lock.json to exist, installs exactly what the lockfile specifies, and fails if package.json and the lockfile disagree. npm install can silently resolve to newer versions, which means the image is not reproducible and a compromised or typosquatted update can slip in . The --omit=dev flag removes test frameworks, linters, and build tools from the production layer.
a. The production Dockerfile structure
The Dockerfile is a single file with three stages. The build stage installs everything and compiles. The deps stage installs production dependencies only. The runtime stage copies artifacts and runs.
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The USER node instruction switches to the non-root user that ships with the official Node images. This is not optional for production: Kubernetes’ restricted pod security standard enforces runAsNonRoot, and a container running as root will not schedule in a properly configured cluster . The node user has UID 1000, which is the convention the official images follow.
The .dockerignore file is equally important. Without it, COPY . . drags node_modules, .git history, .env files, and local logs into the build context and potentially into the image. Secrets baked into a layer persist even if a later layer deletes them . A minimal .dockerignore lists node_modules, .git, .env*, dist, coverage, and *.md.
b. The base image choice and its trade-offs
The base image determines the size floor and the compatibility ceiling. The node:22-slim variant is built on Debian with glibc, stripped of the full userland and build tooling. It is roughly 200 MB, and it preserves compatibility with native addons that compile against glibc . The node:22-alpine variant is smaller—often under 60 MB—but it uses musl libc instead of glibc, and any dependency that ships a native binary compiled against glibc can fail to load or requires a from-source rebuild against musl . This is not a rare edge case; it is the most frequently reported friction point in Alpine adoption for Node.js.
Google’s distroless Node.js image goes further: it ships only the Node.js runtime and its direct shared library dependencies, with no shell, no package manager, and no /bin/sh. The security benefit is that a remote code execution bug cannot be escalated into an interactive reverse shell because there is no shell to launch . The cost is debugging: standard kubectl exec -it -- sh workflows do not work, and you must use ephemeral debug containers or the :debug variant tag for non-production environments.
The practical rule is: use -slim if you have native addons, distroless if you do not and want the smallest attack surface, and reserve -alpine for projects you have actually verified build clean against musl . Size is one signal, not the goal. A 50 MB image with 30 CVEs is worse than a 75 MB image with 5 CVEs. The goal is the smallest image that still runs your application correctly and has the fewest vulnerabilities.
c. Non-root execution and the volume mount interaction
Switching to USER node is a single line in the Dockerfile, but it has operational consequences that are easy to miss. The node user cannot write to directories owned by root, which means any directory the application writes to at runtime—logs, uploads, caches, session files—must be owned by the node user before the switch. The pattern is to chown only the directories the process actually writes to, not the entire /app directory. A recursive chown on /app with a large node_modules tree slows the build significantly, and those files are read-only at runtime anyway .
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node --from=build /app/dist ./dist
RUN mkdir -p /app/logs && chown node:node /app/logs
USER node
The COPY --chown flag sets ownership during the copy, which is more efficient than a separate chown layer. The mkdir and chown for the logs directory ensures the application can write to it after the USER switch.
The volume mount interaction is the second consequence. If the application writes to a mounted volume, the host directory must be owned by the UID that the container process runs as. With the node user, that is UID 1000. A bind mount from a host directory owned by root will be unwritable by the container process. The fix is to chown the host directory to UID 1000 before the container starts, and to do this in both the deploy workflow and any first-boot provisioning script . This is the “three-file sync rule”: the Dockerfile, the deploy workflow, and the provisioning script must all agree on the UID and the directories that need ownership.
A related issue is the git config problem. If the application or its startup script calls git, the non-root user does not have the implicit configuration that root has. The user.email and user.name must be set explicitly, or the git commit call fails . This is a specific case of the general principle: anything that assumed root privileges must be audited when switching to a non-root user.
Complete Example Session
The following session builds a production image with a three-stage Dockerfile, verifies the size reduction, checks the non-root user, and tests that the application can write to its log directory.
# ============================================
# PART 1: THE MULTI-STAGE DOCKERFILE
# ============================================
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node --from=build /app/dist ./dist
RUN mkdir -p /app/logs && chown node:node /app/logs
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
# ============================================
# PART 2: BUILD AND MEASURE
# ============================================
docker build -t api:1.0 .
docker images api:1.0 --format "{{.Size}}"
# Expected: ~250MB
# Compare with a single-stage build of the same app:
# docker build -f Dockerfile.single -t api:single .
# docker images api:single --format "{{.Size}}"
# Expected: ~1.2GB
# ============================================
# PART 3: VERIFY THE NON-ROOT USER
# ============================================
docker run --rm api:1.0 id
# Expected: uid=1000(node) gid=1000(node)
# ============================================
# PART 4: VERIFY WRITABLE LOG DIRECTORY
# ============================================
docker run --rm api:1.0 touch /app/logs/test.log
# Expected: no permission error
# ============================================
# PART 5: VERIFY THE BUILD TOOLCHAIN IS ABSENT
# ============================================
docker run --rm api:1.0 which tsc
# Expected: not found (TypeScript compiler not in runtime)
docker run --rm api:1.0 ls node_modules/.bin
# Expected: only production binaries, no jest, no tsc
The five parts cover the Dockerfile, the size measurement, the user verification, the writable directory check, and the toolchain absence check. What the session demonstrates is that the size reduction is structural—the compiler and test tools are in the build stage, not the runtime stage—and the non-root user is verifiable with a single command.
Quick Reference
Multi-Stage Structure
| Stage | Purpose | Contents |
|---|---|---|
| build | Compile | All deps, source, compiler |
| deps | Production deps | node_modules with --omit=dev |
| runtime | Run | dist, production node_modules |
Base Image Comparison
| Image | Size | Native Addons | Shell |
|---|---|---|---|
node:22 | ~1.1 GB | ✅ glibc | ✅ |
node:22-slim | ~200 MB | ✅ glibc | ✅ |
node:22-alpine | ~150 MB | ⚠️ musl | ✅ |
distroless/nodejs | ~100 MB | ✅ glibc | ❌ |
Critical Instructions
| Instruction | Purpose |
|---|---|
npm ci | Reproducible install from lockfile |
--omit=dev | Exclude dev dependencies |
USER node | Non-root execution |
COPY --chown=node:node | Correct file ownership |
mkdir && chown | Writable directories |
Non-Root Checklist
| Check | Command |
|---|---|
| User is non-root | docker run --rm image id |
| Log dir writable | docker run --rm image touch /app/logs/test |
| Host volume ownership | chown 1000:1000 /host/path |
| Git config (if needed) | Set user.email and user.name |
Best Practices
✅ Do This:
FROM node:22-slim AS build
RUN npm ci && npm run build
FROM node:22-slim AS runtime
COPY --chown=node:node --from=build /app/dist ./dist
USER node
❌ Don’t Do This:
FROM node:22
RUN npm install
COPY . .
CMD npm start
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Image over 1 GB | Single-stage, dev deps included | Multi-stage + --omit=dev |
| Native addon fails on Alpine | musl vs glibc mismatch | Use -slim |
| Permission denied on logs | Directory owned by root | chown node:node before USER |
| Volume mount unwritable | Host directory owned by root | chown 1000:1000 on host |
git fails in startup | Non-root user lacks config | Set user.email and user.name |
Real-World Examples
1. Multi-stage build
FROM node:22-slim AS build
RUN npm ci && npm run build
FROM node:22-slim AS runtime
COPY --from=build /app/dist ./dist
2. Production deps only
RUN npm ci --omit=dev
3. Non-root user
USER node
4. Correct file ownership
COPY --chown=node:node --from=build /app/dist ./dist
5. Writable log directory
RUN mkdir -p /app/logs && chown node:node /app/logs
6. Verify user
docker run --rm image id
7. Verify toolchain absent
docker run --rm image which tsc
8. Host volume ownership
chown 1000:1000 /mnt/data/uploads
9. Git config for non-root
RUN git config --global user.email "app@example.com"
10. Pin base image
FROM node:22.5.1-slim@sha256:...
Visual
Single-Stage vs Multi-Stage
┌──────────────────────────────────────────────┐
│ SINGLE-STAGE (1.2 GB) │
│ node:22 base (~1.1 GB) │
│ + node_modules (all deps) │
│ + source code │
│ + TypeScript compiler │
│ + test frameworks │
│ → runs as root │
│ │
│ MULTI-STAGE (240 MB) │
│ node:22-slim runtime (~200 MB) │
│ + production node_modules │
│ + compiled dist │
│ → runs as node user │
└──────────────────────────────────────────────┘
Non-Root Ownership Flow
┌──────────────────────────────────────────────┐
│ Build as root │
│ │ │
│ ▼ │
│ COPY --chown=node:node │
│ (files owned by node from the start) │
│ │ │
│ ▼ │
│ mkdir /app/logs && chown node:node │
│ (writable directory for runtime) │
│ │ │
│ ▼ │
│ USER node │
│ (all subsequent instructions as node) │
│ │ │
│ ▼ │
│ CMD ["node", "dist/server.js"] │
└──────────────────────────────────────────────┘
Volume Mount Ownership
┌──────────────────────────────────────────────┐
│ HOST │
│ /mnt/data/uploads owned by root │
│ │ │
│ ▼ │
│ Container runs as node (UID 1000) │
│ │ │
│ ▼ │
│ Cannot write to /mnt/data/uploads ❌ │
│ │
│ FIX: chown 1000:1000 /mnt/data/uploads │
│ (on host, before container start) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Multi-stage size reduction | ~80% (1.2 GB → 240 MB) |
| Build stage | Full tooling, compiles |
| Runtime stage | Minimal, runs |
| Production deps | npm ci --omit=dev |
| Non-root user | USER node (UID 1000) |
| File ownership | COPY --chown=node:node |
| Writable dirs | mkdir && chown before USER |
| Host volume | chown 1000:1000 |
| Base image default | node:22-slim |
Key takeaways:
- Multi-stage builds are the largest single size win. The build toolchain, dev dependencies, and source code never reach the runtime image. The final image contains only compiled artifacts and production
node_modules. npm ci --omit=devis mandatory for reproducibility. It installs exactly what the lockfile specifies and excludes test frameworks, linters, and compilers .USER nodeis a deployment requirement. Kubernetes’ restricted pod security standard enforcesrunAsNonRoot, and a container running as root will not schedule in a properly configured cluster .- File ownership must be set before the
USERswitch.COPY --chown=node:nodeandmkdir && chownensure the non-root user can read the application and write to its log directory . - Host volume mounts must match the container UID. A bind mount from a root-owned host directory is unwritable by a container running as UID 1000. The
chownmust happen in the deploy workflow and the provisioning script . - The base image choice is a compatibility trade-off.
-slimis the safe default for native addons. Distroless is the hardened choice for pure-JavaScript applications. Alpine is for projects verified against musl .
Remember: Multi-stage builds and non-root execution are not independent optimizations. They are two halves of the same production discipline: ship only what the application needs to run, and run it with the least privilege required. The multi-stage structure ensures that the compiler and test suite are not in the image. The non-root user ensures that a compromised process cannot write to the filesystem or escalate privileges. The two changes together reduce the image size by 80% and eliminate a category of security exposure that is otherwise invisible until it is exploited. The Dockerfile is not a build script—it is the definition of what ships. Every line that puts something in the final image that the application does not need is a line that adds weight and risk for no benefit.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!