A 2 GB Node.js Docker image is not a badge of honor — it is a symptom of an un-optimized Dockerfile. In 2026, a typical Node.js web server should ship as a ~150–200 MB image. A Go or Rust binary can fit in ~30 MB. Here is how to get there.
What changed in 2026
- Docker BuildKit is the default —
DOCKER_BUILDKIT=1 is no longer needed; BuildKit features (mount caches, heredoc syntax) are available everywhere.
--cache-mount for package managers is now the canonical way to cache npm, pip, and apt downloads across builds without leaking them into the final layer.
docker scout (formerly Docker Desktop vulnerability scanning) identifies image layers with known CVEs and suggests slimmer base images.
- Buildpacks (
pack CLI) remain an alternative to manual Dockerfiles for standard app shapes, auto-selecting a minimal base.
The problem: what makes images large
| Cause |
Typical size contribution |
Wrong base image (e.g., node:22 instead of node:22-alpine) |
+400–800 MB |
| Build tools in runtime image (gcc, make, git) |
+200–400 MB |
node_modules with dev dependencies |
+200–500 MB |
| Unignored source files copied in |
+50–200 MB |
Multiple RUN layers creating duplicates |
+50–200 MB |
Step 1 — .dockerignore
Always create this before writing the Dockerfile:
node_modules
.git
.next
dist
coverage
*.log
.env*
README.md
*.test.ts
*.spec.ts
Without .dockerignore, the build context sends everything to the Docker daemon, and COPY . . can copy hundreds of MB of files you do not want.
Step 2 — multi-stage build
A single-stage Dockerfile for Node:
# Naive — ~1.2 GB
FROM node:22
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["node", "dist/index.js"]
Multi-stage version — ~160 MB:
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --include=dev
COPY . .
RUN npm run build
# Stage 2: Runtime
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
The runtime stage contains no build tools, no dev dependencies, and no source code — only the compiled output and production node_modules.
Step 3 — choose the right base image
| Base image |
Compressed size |
Use case |
node:22 |
~350 MB |
Development only |
node:22-slim |
~75 MB |
Good default runtime |
node:22-alpine |
~50 MB |
Smallest; watch for musl libc compat issues |
gcr.io/distroless/nodejs22 |
~45 MB |
No shell, minimal attack surface |
For Go/Rust, compile a static binary and use FROM scratch or gcr.io/distroless/static:
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM gcr.io/distroless/static
COPY --from=builder /app/server /server
CMD ["/server"]
# Result: ~15 MB
Layer caching strategy
Put instructions from least-to-most frequently changing:
# 1. Base — never changes
FROM node:22-alpine
# 2. System dependencies — rarely changes
RUN apk add --no-cache dumb-init
# 3. Package files — changes when dependencies change
COPY package*.json ./
RUN npm ci --omit=dev
# 4. Application code — changes often
COPY --from=builder /app/dist ./dist
When only application code changes, Docker reuses cached layers 1–3 and only re-executes layer 4.
Analyze image size
# Show layer sizes
docker image history my-image:latest
# Detailed analysis with dive
brew install dive
dive my-image:latest
# Check for vulnerabilities and suggest slimmer bases
docker scout recommendations my-image:latest
dive shows exactly which files are in each layer and flags wasted space.
How to pick your optimization strategy
- Node app, simple shape? → Multi-stage +
node:22-alpine runtime.
- Node app with native modules? →
node:22-slim (Debian); Alpine's musl libc can break native addons.
- Go or Rust binary? →
gcr.io/distroless/static or scratch.
- Security-critical image? →
gcr.io/distroless — no shell, no package manager, minimal attack surface.
- Need
apt-get at runtime? → Ask why; most cases can use a build stage instead.
Common mistakes
Merging RUN commands that should be separate. Combining all apt-get commands into one RUN is correct (avoids layer duplication), but do not merge unrelated concerns.
Running as root. Add USER node (or USER 1000) before CMD. Running as root in a container is a privilege escalation risk.
Not cleaning apt cache. Always rm -rf /var/lib/apt/lists/* after apt-get install to remove the package index from the layer.
Copying .env files into the image. Use Docker secrets or environment variables at runtime — never bake credentials into the image layer.
What to skip
apt-get install curl wget git in runtime images. These tools are useful for debugging but increase attack surface. Use a debug image variant for troubleshooting.
- Copying all of
node_modules from builder to runtime. Run npm ci --omit=dev in the runtime stage to get only production dependencies.
- Manual layer squashing. BuildKit's
--squash flag is rarely needed; multi-stage builds are cleaner.
FAQ
How do I debug a distroless image with no shell?
Use docker debug (Docker Desktop) or a debug variant: gcr.io/distroless/nodejs22:debug includes busybox.
Should I use Alpine for all images?
Alpine is great for most Node and Python apps, but native modules compiled against glibc will fail on Alpine (musl). Test before committing to Alpine in production.
How small can a Node.js image actually get?
With gcr.io/distroless/nodejs22 and production-only node_modules, a typical API server image lands around 100–180 MB. Going smaller requires static compilation, which Node does not support.
Does image size affect Kubernetes pod startup time?
Yes — pulling a 1.5 GB image versus a 150 MB image saves 10–30 seconds on a cold node pull, depending on registry location and network.
Where to go next