Docker Image Optimization: Reducing Size and Build Time
Practical techniques for reducing Docker image size and build time for Node.js applications, covering base image selection, multi-stage builds, layer caching, BuildKit, and size analysis tools.
Docker Image Optimization: Reducing Size and Build Time
Overview
Docker image optimization is the practice of systematically reducing the size, build time, and attack surface of your container images through base image selection, layer management, multi-stage builds, and build cache strategies. For Node.js applications, the difference between a naive Dockerfile and an optimized one is often the difference between a 1.2 GB image that takes four minutes to build and a sub-100 MB image that builds in under thirty seconds on cache hit. If you deploy containers to production — whether on Kubernetes, DigitalOcean App Platform, AWS ECS, or anywhere else — image optimization directly impacts your deployment speed, infrastructure costs, cold start times, and security posture.
Prerequisites
- Docker Engine 18.09+ (BuildKit support)
- Working knowledge of Node.js and npm
- Familiarity with basic Dockerfile instructions (FROM, COPY, RUN, CMD)
- A terminal with
dockerCLI available - Optional:
diveinstalled for image analysis (brew install diveor download from GitHub)
Understanding Docker Image Layers
Every instruction in a Dockerfile creates a layer. Layers are stacked and cached independently. When Docker builds an image, it checks each instruction against the cache — if the instruction and its context have not changed, Docker reuses the cached layer instead of executing the instruction again.
This has two critical implications:
Order matters. If you change a file that gets copied early in the Dockerfile, every subsequent layer is invalidated and rebuilt. Put instructions that change infrequently at the top and instructions that change frequently at the bottom.
Layers only add size. Even if you delete a file in a later
RUNinstruction, the file still exists in the previous layer. The final image size is the sum of all layers. You cannot shrink an earlier layer by removing files in a later one — you can only avoid creating large layers in the first place.
Let us look at this concretely:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
RUN rm -rf node_modules
RUN npm install --production
You might think the rm -rf node_modules step would reclaim the space from the full npm install. It does not. The devDependencies layer is still there, baked into the image. The rm only adds a whiteout layer on top. Your image is now larger than if you had never removed anything.
You can verify this yourself:
docker build -t myapp-naive .
docker history myapp-naive
IMAGE CREATED SIZE COMMENT
a1b2c3d4e5f6 2 seconds ago 145MB RUN npm install --production
f6e5d4c3b2a1 5 seconds ago 0B RUN rm -rf node_modules
1a2b3c4d5e6f 8 seconds ago 312MB RUN npm run build
6f5e4d3c2b1a 15 seconds ago 487MB RUN npm install
...
That 487 MB layer from the full npm install is permanent. The only way to eliminate it from the final image is a multi-stage build, which we will cover shortly.
Choosing Base Images
The base image you select determines the floor of your image size. Nothing else you do matters if your base image is 950 MB. Here is a real comparison using node:20 variants:
docker pull node:20
docker pull node:20-slim
docker pull node:20-alpine
docker images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
REPOSITORY:TAG SIZE
node:20 1.1GB
node:20-slim 219MB
node:20-alpine 135MB
node:20 (Debian Bookworm)
The full image. Includes build-essential, Python, gcc, make, git, curl, and hundreds of other packages. You need this as a build stage if you compile native addons (bcrypt, sharp, better-sqlite3). You almost never need it as a runtime image.
node:20-slim (Debian Bookworm, minimal)
Debian with most build tools stripped out. Good runtime base when you need glibc compatibility but not compilation tools. About 80% smaller than the full image.
node:20-alpine (Alpine Linux + musl libc)
Alpine Linux with musl libc instead of glibc. The smallest official Node.js image. This is my default recommendation for most Node.js applications. The musl/glibc difference matters for native modules — we will cover the gotchas later in this article.
Distroless (gcr.io/distroless/nodejs20-debian12)
Google's distroless images contain only the runtime and your application. No shell, no package manager, no coreutils. The smallest possible image with the smallest possible attack surface. The trade-off is that you cannot docker exec into the container for debugging. This is actually a feature in production.
docker pull gcr.io/distroless/nodejs20-debian12
docker images gcr.io/distroless/nodejs20-debian12
REPOSITORY TAG SIZE
gcr.io/distroless/nodejs20-debian12 latest 128MB
Distroless is roughly the same size as Alpine, but uses glibc, which avoids the musl compatibility issues entirely. If you do not need a shell in your production containers, distroless is the strongest choice.
Multi-Stage Builds for Production
Multi-stage builds are the single most impactful optimization you can apply. The idea is simple: use one stage to install dependencies and build your application, then copy only the production artifacts into a clean, minimal final stage.
# Stage 1: Install all dependencies and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production image
FROM node:20-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The builder stage has all devDependencies, build tools, source code, and intermediate artifacts. None of that makes it into the final image. The production stage starts from a clean Alpine image, installs only production dependencies, and copies the built output. The builder stage layers are discarded entirely from the final image.
.dockerignore Best Practices
Before COPY . . runs, Docker sends the entire build context to the daemon. Without a .dockerignore, that includes your .git directory, node_modules, test files, documentation, and anything else in your project root. This slows down the build and can bloat your image.
Create a .dockerignore file at your project root:
node_modules
npm-debug.log*
.git
.gitignore
.dockerignore
Dockerfile
docker-compose*.yml
.env
.env.*
.nyc_output
coverage
test
tests
__tests__
*.test.js
*.spec.js
.eslintrc*
.prettierrc*
.editorconfig
README.md
CHANGELOG.md
LICENSE
docs
.vscode
.idea
This is not just about image size — it is about build speed. Sending a 500 MB .git directory to the Docker daemon adds seconds to every build, even when nothing else has changed.
Measure the impact:
# Without .dockerignore
time docker build -t myapp-no-ignore .
# Sending build context to Docker daemon 523.4MB
# With .dockerignore
time docker build -t myapp-with-ignore .
# Sending build context to Docker daemon 2.1MB
That is a 250x reduction in build context size.
Layer Caching Optimization
The order of your Dockerfile instructions determines how effectively Docker can cache layers between builds. The most common mistake is copying all source files before installing dependencies:
Bad: Source changes invalidate dependency cache
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci
Every time you change any source file, the COPY . . layer is invalidated, which invalidates the npm ci layer, which forces a full dependency reinstall. On a project with 800 dependencies, that is two minutes wasted on every build.
Good: Dependencies cached independently of source code
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Now npm ci is only re-executed when package.json or package-lock.json changes. Source code changes only invalidate the final COPY . . layer, which is nearly instant.
Advanced: Split dependency types
If you have a multi-stage build where the builder needs devDependencies but the production stage does not, you can separate them:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/server.js"]
This pattern has four distinct stages. The deps and prod-deps stages are cached independently. If only your source code changes, Docker skips both dependency installation stages entirely and only re-runs the builder. This cuts build times dramatically on iterative development.
Reducing node_modules Size
The node_modules directory is usually the largest component of a Node.js Docker image. Here are the key techniques for trimming it.
npm ci --omit=dev
Always use npm ci --omit=dev (or npm ci --production on older npm versions) in your production stage. This skips devDependencies entirely:
# Full install
du -sh node_modules
# 312M node_modules
# Production only
npm ci --omit=dev
du -sh node_modules
# 87M node_modules
npm prune --production
If you have already installed all dependencies (e.g., in a build stage), you can retroactively remove devDependencies:
npm prune --production
This is useful in single-stage builds but unnecessary in multi-stage builds where you install production dependencies in a separate stage.
npm ci vs npm install
Always use npm ci in Docker builds, not npm install. The differences matter:
npm cideletesnode_modulesand installs frompackage-lock.jsonexactly. Deterministic, reproducible.npm installmay updatepackage-lock.jsonand resolves versions at install time. Non-deterministic across builds.npm ciis faster because it skips the dependency resolution step.
# Good
RUN npm ci --omit=dev
# Bad
RUN npm install --production
Clean npm cache
npm caches downloaded packages in ~/.npm. In a Docker build, this cache consumes space in the layer and is never reused (unless you use BuildKit cache mounts). Remove it:
RUN npm ci --omit=dev && npm cache clean --force
Combining the install and cache clean in a single RUN statement ensures the cache never exists in a committed layer.
BuildKit Features
Docker BuildKit is the modern build engine, enabled by default in Docker Engine 23.0+. It provides significant performance and security features that you should be using.
Enabling BuildKit
# Environment variable (Docker < 23.0)
export DOCKER_BUILDKIT=1
docker build .
# Docker 23.0+ has BuildKit enabled by default
docker build .
# Or use buildx explicitly
docker buildx build .
Cache Mounts
Cache mounts let you persist directories between builds without baking them into image layers. This is transformative for npm installs:
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
COPY . .
The npm cache directory (/root/.npm) is mounted from the host's build cache. On subsequent builds, npm can resolve packages from the cache instead of downloading them. The cache is not included in the final image layer — it only exists during the RUN instruction.
The first build behaves normally. Subsequent builds with the same or overlapping dependencies see dramatic speedups:
# First build
time docker build -t myapp .
# real 0m47.3s
# Second build (cache hit)
time docker build -t myapp .
# real 0m8.1s
Secret Mounts
If you need to access private npm registries during the build, use secret mounts instead of build arguments. Build arguments are visible in docker history and in the image layers.
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --omit=dev
Build with:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
The .npmrc file with your auth token is available during the RUN instruction but is never written to any image layer. It disappears after the instruction completes.
Parallel stage execution
BuildKit builds independent stages in parallel. In our four-stage Dockerfile from earlier, the deps and prod-deps stages have no dependency on each other — BuildKit runs them simultaneously:
[+] Building 22.4s (14/14) FINISHED
=> [deps 1/3] COPY package.json package-lock.json ./ 0.1s
=> [prod-deps 1/3] COPY package.json package-lock.json ./ 0.1s
=> [deps 2/3] RUN npm ci 18.2s
=> [prod-deps 2/3] RUN npm ci --omit=dev 12.1s
=> [builder 1/3] COPY --from=deps /app/node_modules ... 1.4s
=> [builder 2/3] COPY . . 0.2s
=> [builder 3/3] RUN npm run build 3.8s
=> [production 1/3] COPY --from=prod-deps ... 0.9s
Notice that deps and prod-deps execute simultaneously. Without BuildKit, these would run sequentially.
Analyzing Image Size
You cannot optimize what you cannot measure. Two tools are essential for understanding what is taking up space in your images.
docker history
Built into Docker. Shows every layer and its size:
docker history myapp --format "table {{.CreatedBy}}\t{{.Size}}"
CREATED BY SIZE
CMD ["node" "dist/server.js"] 0B
EXPOSE map[3000/tcp:{}] 0B
USER node 0B
COPY dir:a1b2c3... /app/dist 2.41MB
RUN npm ci --omit=dev 87.3MB
COPY file:abc123... /app/package-lock.json 412kB
COPY file:def456... /app/package.json 1.2kB
WORKDIR /app 0B
/bin/sh -c #(nop) CMD ["node"] 0B
...alpine base layers... 7.8MB
This tells you exactly which instruction is responsible for the largest layers. If your npm ci layer is 300 MB, that is where to focus.
dive
dive is an open-source tool that lets you interactively explore each layer of a Docker image, seeing exactly which files were added, modified, or deleted:
# Install
brew install dive # macOS
snap install dive # Ubuntu
# Analyze an image
dive myapp
dive shows a tree view of the filesystem at each layer. You can immediately spot problems — a 50 MB test/ directory that should have been excluded, or a node_modules/.cache directory consuming 100 MB.
dive also calculates an "efficiency score" that estimates how much wasted space exists in your image due to duplicate or deleted files across layers.
dive myapp --ci
efficiency: 98.4%
wastedBytes: 1.2 MB
userWastedPercent: 1.6%
Run dive --ci in your CI pipeline to catch efficiency regressions before they ship.
Native Dependencies and Alpine Gotchas
Alpine Linux uses musl libc instead of glibc. Most Node.js applications work fine on musl, but native addons compiled against glibc will fail on Alpine. This is the most common source of "it works on my machine but not in Docker" issues.
Packages that commonly cause problems
- bcrypt — Compiles C++ code against libc. Needs
python3,make, andg++installed. Usebcryptjs(pure JavaScript) to avoid this entirely. - sharp — Image processing library with native bindings to libvips. Requires specific Alpine packages:
vips-dev,fftw-dev,build-base. - better-sqlite3 — Requires compilation. Needs
python3,make,g++. - canvas (node-canvas) — Requires
cairo-dev,pango-dev,jpeg-dev,giflib-dev,librsvg-dev.
The musl vs glibc error
When you see this error, it means a native module was compiled against glibc but you are running on Alpine (musl):
Error: /app/node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node:
Error loading shared library ld-linux-x86-64.so.2:
No such file or directory
Or this variant:
Error: Error loading shared library libstdc++.so.6:
No such file or directory (needed by /app/node_modules/sharp/build/Release/sharp-linux-x64.node)
Solution: Multi-stage with Alpine build tools
# Stage 1: Build native modules on Alpine
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++ vips-dev
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Runtime with shared libraries only
FROM node:20-alpine AS production
RUN apk add --no-cache vips
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Notice that the builder stage installs vips-dev (the development headers) while the production stage only installs vips (the runtime shared library). The headers and compilers stay in the builder stage — they never make it into the final image.
Alternative: Just use the full node image for building
If you are fighting with Alpine build dependencies, use node:20 (Debian) for the builder stage and node:20-alpine for the runtime stage:
FROM node:20 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
This avoids all musl compatibility issues during the build. The Debian builder stage compiles everything against glibc, but since we run npm ci --omit=dev fresh in the Alpine production stage, native production dependencies are recompiled against musl in the final image. The builder stage's glibc artifacts are discarded entirely.
Security Scanning and Removing Unnecessary Packages
Every package in your image is a potential vulnerability. Smaller images have fewer CVEs, period.
Remove unnecessary system packages
On Alpine, be explicit about what you add and clean up after yourself:
FROM node:20-alpine
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& npm ci \
&& apk del .build-deps
The --virtual .build-deps flag creates a virtual package group that you can delete in a single command. Combined in one RUN instruction, the build tools never persist in the final layer.
Scan with Docker Scout or Trivy
# Docker Scout (built into Docker Desktop)
docker scout cves myapp
# Trivy (open source)
trivy image myapp
myapp (alpine 3.19.1)
=====================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Node.js (node_modules/package-lock.json)
========================================
Total: 2 (UNKNOWN: 0, LOW: 1, MEDIUM: 1, HIGH: 0, CRITICAL: 0)
+-----------+------------------+----------+-------------------+
| Library | Vulnerability | Severity | Installed Version |
+-----------+------------------+----------+-------------------+
| semver | CVE-2022-25883 | MEDIUM | 7.3.7 |
| minimatch | CVE-2022-3517 | LOW | 3.0.4 |
+-----------+------------------+----------+-------------------+
Compare scanning results between the full Debian image and Alpine — the difference in CVE count is typically dramatic. I have seen projects go from 147 vulnerabilities on node:20 to 2 on node:20-alpine, purely from the base image reduction.
Build Arguments and Conditional Layers
Use build arguments to control what gets included in the image at build time:
FROM node:20-alpine
WORKDIR /app
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
COPY package.json package-lock.json ./
RUN if [ "$NODE_ENV" = "production" ]; then \
npm ci --omit=dev; \
else \
npm ci; \
fi
COPY . .
CMD ["node", "server.js"]
Build for different environments:
# Production (default)
docker build -t myapp:prod .
# Development (includes devDependencies)
docker build --build-arg NODE_ENV=development -t myapp:dev .
Warning: Do not use build arguments for secrets. They are visible in the image history:
docker history myapp
# ARG NODE_ENV=production <-- visible to anyone who pulls the image
Use secret mounts for anything sensitive (API keys, registry tokens, private SSH keys).
Comparing Image Sizes Across Approaches
Here is a real comparison I ran on a medium-sized Express.js API with 47 production dependencies and 83 devDependencies:
| Approach | Image Size | Build Time (cold) | Build Time (cached) |
|---|---|---|---|
node:20 + npm install |
1.21 GB | 68s | 62s |
node:20 + npm ci --omit=dev |
1.04 GB | 52s | 48s |
node:20-slim + npm ci --omit=dev |
264 MB | 41s | 35s |
node:20-alpine + npm ci --omit=dev |
172 MB | 38s | 32s |
| Multi-stage (Alpine build + Alpine runtime) | 94 MB | 42s | 8s |
| Multi-stage + BuildKit cache mounts | 94 MB | 42s | 6s |
| Multi-stage + distroless runtime | 91 MB | 44s | 9s |
The naive approach is 13x larger than the optimized multi-stage build. The cached build time drops from over a minute to six seconds.
Complete Working Example
Let us walk through optimizing a real Node.js Express application from a naive 1.2 GB image down to under 100 MB. I will show each step with measured sizes.
The Application
// server.js
var express = require("express");
var compression = require("compression");
var helmet = require("helmet");
var morgan = require("morgan");
var app = express();
var port = process.env.PORT || 3000;
app.use(helmet());
app.use(compression());
app.use(morgan("combined"));
app.use(express.json());
app.get("/health", function(req, res) {
res.json({ status: "ok", uptime: process.uptime() });
});
app.get("/api/users", function(req, res) {
res.json([
{ id: 1, name: "Alice", role: "engineer" },
{ id: 2, name: "Bob", role: "designer" },
{ id: 3, name: "Charlie", role: "manager" }
]);
});
app.get("/api/users/:id", function(req, res) {
var users = {
"1": { id: 1, name: "Alice", role: "engineer" },
"2": { id: 2, name: "Bob", role: "designer" },
"3": { id: 3, name: "Charlie", role: "manager" }
};
var user = users[req.params.id];
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json(user);
});
app.listen(port, function() {
console.log("Server running on port " + port);
});
{
"name": "docker-optimization-demo",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "mocha --recursive",
"lint": "eslint ."
},
"dependencies": {
"compression": "^1.7.4",
"express": "^4.18.2",
"helmet": "^7.1.0",
"morgan": "^1.10.0"
},
"devDependencies": {
"eslint": "^8.56.0",
"mocha": "^10.2.0",
"chai": "^4.3.10",
"supertest": "^6.3.3",
"nodemon": "^3.0.2",
"nyc": "^15.1.0"
}
}
Step 1: The Naive Dockerfile (1.21 GB)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
docker build -t demo:naive .
docker images demo:naive
REPOSITORY TAG IMAGE ID SIZE
demo naive a1b2c3d4e5f6 1.21GB
Problems: Full Debian base image (950 MB), all devDependencies installed, entire project directory copied (including .git, node_modules, tests).
Step 2: Add .dockerignore and Use npm ci (1.04 GB)
Add the .dockerignore file shown earlier, then:
FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
docker build -t demo:step2 .
docker images demo:step2
REPOSITORY TAG IMAGE ID SIZE
demo step2 b2c3d4e5f6a1 1.04GB
Improvement: 170 MB smaller. devDependencies excluded. Layer caching works for dependency changes. But the base image is still 950 MB.
Step 3: Switch to Alpine (172 MB)
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
docker build -t demo:step3 .
docker images demo:step3
REPOSITORY TAG IMAGE ID SIZE
demo step3 c3d4e5f6a1b2 172MB
Improvement: 868 MB smaller than step 2. The Alpine base image is 135 MB vs 950 MB for Debian. We also clean the npm cache and run as non-root.
Step 4: Multi-Stage Build (94 MB)
# syntax=docker/dockerfile:1
# Stage 1: Install production dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Stage 2: Final production image
FROM node:20-alpine AS production
# Security: run as non-root
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
WORKDIR /app
# Copy only production node_modules
COPY --from=deps /app/node_modules ./node_modules
# Copy application source
COPY package.json ./
COPY server.js ./
# Set ownership
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "var http = require('http'); var options = { hostname: 'localhost', port: 3000, path: '/health', timeout: 2000 }; var req = http.request(options, function(res) { process.exit(res.statusCode === 200 ? 0 : 1); }); req.on('error', function() { process.exit(1); }); req.end();"
CMD ["node", "server.js"]
docker build -t demo:step4 .
docker images demo:step4
REPOSITORY TAG IMAGE ID SIZE
demo step4 d4e5f6a1b2c3 94MB
Improvement: 78 MB smaller than step 3. The multi-stage build ensures only node_modules and our application code exist in the final image — no npm cache, no package-lock.json bloat, no extra files. We also added a health check and custom non-root user.
Step 5: BuildKit Cache Mounts (94 MB, faster builds)
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM node:20-alpine AS production
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json server.js ./
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "var http = require('http'); var options = { hostname: 'localhost', port: 3000, path: '/health', timeout: 2000 }; var req = http.request(options, function(res) { process.exit(res.statusCode === 200 ? 0 : 1); }); req.on('error', function() { process.exit(1); }); req.end();"
CMD ["node", "server.js"]
# First build
time docker build -t demo:step5 .
# real 0m38.2s
# Change server.js, rebuild
time docker build -t demo:step5 .
# real 0m5.8s
docker images demo:step5
REPOSITORY TAG IMAGE ID SIZE
demo step5 e5f6a1b2c3d4 94MB
Same image size, but cached builds now take under 6 seconds instead of 38. The npm cache mount persists across builds without adding to image size.
Final Size Comparison
docker images demo --format "table {{.Tag}}\t{{.Size}}"
TAG SIZE
naive 1.21GB
step2 1.04GB
step3 172MB
step4 94MB
step5 94MB
Total reduction: 1.21 GB down to 94 MB — a 92% reduction.
Common Issues and Troubleshooting
Issue 1: npm ci fails with "could not determine executable to run"
npm ERR! could not determine executable to run
npm ERR! A complete log of this run can be found in:
npm ERR! /root/.npm/_logs/2024-01-15T10_23_45_678Z-debug-0.log
Cause: Your package-lock.json is out of sync with package.json. npm ci requires an exact match.
Fix: Regenerate the lockfile locally before building:
rm package-lock.json
npm install
# Commit the new package-lock.json
docker build -t myapp .
Issue 2: Alpine build fails with "node-gyp" errors
gyp ERR! find Python
gyp ERR! configure error
gyp ERR! stack Error: Could not find any Python installation to use
gyp ERR! not ok
Cause: Native modules require Python, make, and a C++ compiler that Alpine does not include by default.
Fix: Install build tools in your builder stage:
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
Issue 3: Permission denied when running as non-root
Error: EACCES: permission denied, open '/app/logs/app.log'
at Object.openSync (node:fs:601:3)
at Object.writeFileSync (node:fs:2249:35)
Cause: Your application tries to write to the filesystem, but the node or custom user does not own the target directory.
Fix: Create the directory and set ownership before switching to the non-root user:
RUN mkdir -p /app/logs && chown -R appuser:appgroup /app/logs
USER appuser
Or better — write logs to stdout instead of files. Let your container orchestrator handle log collection.
Issue 4: HEALTHCHECK always fails, container marked unhealthy
CONTAINER ID STATUS
a1b2c3d4e5f6 Up 2 minutes (unhealthy)
docker inspect --format='{{json .State.Health}}' a1b2c3d4e5f6
{
"Status": "unhealthy",
"Log": [
{
"ExitCode": 1,
"Output": ""
}
]
}
Cause: The health check command does not have the tools it needs. On Alpine or distroless images, curl and wget are not installed.
Fix: Use Node.js itself for the health check instead of curl:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "var http = require('http'); http.get('http://localhost:3000/health', function(res) { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', function() { process.exit(1); });"
Issue 5: Image builds successfully but crashes at runtime with missing shared library
node: error while loading shared libraries: libstdc++.so.6:
cannot open shared object file: No such file or directory
Cause: You compiled native modules in a Debian builder stage and copied the binaries to an Alpine runtime stage. The binaries are linked against glibc but Alpine uses musl.
Fix: Either install dependencies fresh in the Alpine runtime stage with npm ci, or use Alpine for both stages so native modules compile against musl:
# Both stages use Alpine — consistent libc
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
Best Practices
Always use multi-stage builds. There is no good reason to ship build tools, devDependencies, or intermediate artifacts to production. Multi-stage builds cost nothing in complexity and typically reduce image size by 80% or more.
Pin your base image digest, not just the tag. Tags like
node:20-alpineare mutable — they point to different images over time as patches are applied. For reproducible builds, pin to a digest:node:20-alpine@sha256:abc123.... Update the digest intentionally, not accidentally.Run as a non-root user. Add
USER nodeor create a custom user. Running containers as root is a security vulnerability that every container scanner will flag. The official Node.js Alpine images include anodeuser (UID 1000) by default.Use
npm ci, nevernpm install, in Dockerfiles.npm ciis deterministic, faster, and requires a lockfile. It produces the samenode_modulestree regardless of when or where it runs.npm installcan silently update dependencies between builds.Combine related RUN instructions. Each
RUNcreates a layer. If you install build tools, compile something, and remove the tools, do it all in oneRUNinstruction. Otherwise the tools persist in an earlier layer even after removal.Order instructions from least to most frequently changing. Base image and system packages change rarely — put them first. Dependencies change occasionally — put them in the middle. Source code changes on every build — put it last. This maximizes layer cache reuse.
Use BuildKit cache mounts for package managers.
--mount=type=cache,target=/root/.npmkeeps the npm cache between builds without adding it to image layers. This is strictly superior tonpm cache clean --force— you get faster builds and smaller images simultaneously.Scan images in CI before deploying. Run
trivy imageordocker scout cvesin your pipeline. Set a policy: fail the build on any CRITICAL or HIGH vulnerability. Smaller images with fewer system packages have dramatically fewer CVEs to begin with.Add a .dockerignore file to every project with a Dockerfile. At minimum, exclude
.git,node_modules, test files, and documentation. This reduces build context transfer time and prevents accidentally including sensitive files in the image.Measure before and after every optimization. Use
docker imagesfor total size,docker historyfor per-layer breakdown, anddivefor file-level analysis. Optimization without measurement is just guessing.
References
- Docker official documentation: Multi-stage builds
- Docker official documentation: BuildKit
- Node.js Docker best practices
- Dockerfile reference
- dive - A tool for exploring Docker image layers
- Trivy - Container security scanner
- Google distroless images
- Alpine Linux package index
- npm ci documentation
- Docker Scout documentation
