Docker Optimization
Table of Contents
- Multi-Stage Builds
- Alpine Images
- Layer Caching
- Build Cache
- Reduce Image Size
- Security Best Practices
A bloated image is slow to build, slow to pull, slow to deploy, and carries a larger attack surface than necessary. This section covers the concrete techniques for shrinking images and speeding up builds—demonstrated with real before/after numbers on the multi-container-project/backend NestJS service from Part 11.
1. Multi-Stage Builds
A multi-stage build uses more than one FROM in a single Dockerfile. Each stage can have its own base image and its own tools; only the files you explicitly COPY --from=<stage> make it into the next stage. This lets you use a heavy "build" stage (compilers, dev dependencies) without any of that weight ending up in the final image.
# backend/Dockerfile.prod
# --- Stage 1: build ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# --- Stage 2: production runtime ---
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/main"]
The build stage installs all dependencies (including TypeScript/Nest CLI dev tools) and compiles src/ into dist/. The final stage starts fresh from a clean node:20-alpine, installs only production dependencies, and copies in just the compiled dist/ output—the TypeScript source, dev dependencies, and build tools never reach the final image.
Measured on this repo's backend service:
| Dockerfile | Image Size |
|---|---|
Single-stage (dev, npm install with dev deps) | 560MB |
Multi-stage (Dockerfile.prod above) | 371MB |
# Build a specific stage, or the final one by default
docker build -f Dockerfile.prod -t backend:prod .
# Target an earlier stage directly (useful for debugging the build stage)
docker build -f Dockerfile.prod --target build -t backend:build-only .
2. Alpine Images
Most official images publish an -alpine variant built on Alpine Linux, which uses musl libc and BusyBox instead of a full glibc-based distribution. The full, un-suffixed tag is always the largest by far—that part is consistent across every image family.
# Compare base image sizes directly
docker pull node:20
docker pull node:20-slim
docker pull node:20-alpine
docker images node
Measured locally, pulling all three today:
| Tag | Size |
|---|---|
node:20 | 1.59GB |
node:20-slim | 315MB |
node:20-alpine | 387MB |
-
Both
-slimand-alpineare dramatically smaller than the full image—that's the decision that matters most. Whether-slimor-alpinewins between themselves varies by image and changes over time as base layers are updated, so don't assume Alpine is always the smallest; measure the actual tag you're using withdocker imagesif size is a hard requirement. -
Caveat: Alpine's
musl libcis not always binary-compatible with packages that ship precompiledglibcbinaries (common with some native Node.js addons). If a package fails to install or run only on Alpine, that's usually why—either find an Alpine-compatible build or fall back to a-slimvariant.
3. Layer Caching
Recap from Part 5: Docker caches each instruction's resulting layer and reuses it on the next build if nothing that affects it has changed. Instruction order matters—put whatever changes least often first.
# GOOD: dependency install is cached until package*.json actually changes
COPY package*.json ./
RUN npm install
COPY . .
# BAD: any source code edit invalidates npm install too, forcing a full reinstall
COPY . .
RUN npm install
# See which layers were cache hits ("CACHED") vs rebuilt during a build
docker build -t backend:prod -f Dockerfile.prod .
4. Build Cache
Beyond instruction ordering, Docker's build cache can be inspected, warmed from a remote image, and explicitly bypassed when needed.
# Force a clean rebuild, ignoring all cached layers
docker build --no-cache -t backend:prod -f Dockerfile.prod .
# Use a previously pushed image as a remote cache source (useful in CI,
# where each build otherwise starts with an empty local cache)
docker build \
--cache-from myregistry/backend:prod \
-t myregistry/backend:prod \
-f Dockerfile.prod .
# Inspect disk space used by the build cache
docker builder du
# Clear the build cache
docker builder prune
5. Reduce Image Size
Beyond multi-stage builds and Alpine base images, a few more habits keep images lean:
-
.dockerignoreeverything unnecessary:node_modules,.git, test files, and docs shouldn't enter the build context at all (recap: Part 7). -
Combine
RUNcommands that install and clean up in the same layer—cleanup in a later layer doesn't shrink earlier layers, since each layer is immutable once written:# GOOD: apt cache never persists in any layer RUN apt-get update && apt-get install -y curl \ && rm -rf /var/lib/apt/lists/* # BAD: apt cache is already baked into the first layer, prune only shrinks the writable layer RUN apt-get update && apt-get install -y curl RUN rm -rf /var/lib/apt/lists/* -
Install only production dependencies in the final image:
npm install --omit=dev(ornpm ci --omit=dev) instead of a fullnpm install. -
Avoid unnecessary tags/duplicate images:
docker image pruneregularly to remove dangling/unused layers left over from iterative builds (recap: Part 3). -
Check what's actually inside an image when size seems off:
docker history backend:prod
6. Security Best Practices
A smaller image is also a more secure one—fewer packages means fewer CVEs. A few additional hardening steps:
-
Run as a non-root user: Both stages above use
USER node(thenodeimage ships a built-in non-rootnodeuser) so the process never runs asrootinside the container.USER node -
Pin exact base image versions: Avoid
:latestin production Dockerfiles (recap: Part 5, section 4)—an unpinned base can silently introduce a breaking or vulnerable version. -
Scan images for known vulnerabilities:
# Docker's built-in scanner docker scout cves backend:prod # Or the widely used open-source alternative trivy image backend:prod -
Never include secrets in the image (recap: Part 12, section 3)—a
docker historyordocker savecan expose anything baked into a layer, even if a later layer deletes it. -
Keep the base image updated: Rebuild periodically (
docker build --pull) to pick up upstream security patches to the base OS layer, rather than pinning a base image forever.
Summary Cheat Sheet
| Task | Command |
|---|---|
| Build a specific Dockerfile | docker build -f Dockerfile.prod -t <tag> . |
| Build only an earlier stage | docker build --target <stage> -t <tag> . |
| Force rebuild, ignore cache | docker build --no-cache -t <tag> . |
| Use a remote image as build cache | docker build --cache-from <image> -t <tag> . |
| Inspect build cache disk usage | docker builder du |
| Clear the build cache | docker builder prune |
| View image layer history/sizes | docker history <image> |
| Scan for vulnerabilities | docker scout cves <image> / trivy image <image> |
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!