ðŠķ Image Optimization
Multi-stage builds got you most of the way. This lesson is the finishing polish: the base-image choices, layer tricks, and dependency pruning that take a good image and make it a lean, fast, secure one â and the tools to prove every megabyte is earning its place.
Week 11 · Day 2 (Tuesday: Creating Docker Images) · Lecture 4
ðŊ Learning Objectives
By the end of this lesson, you will be able to:
- Explain why small images deploy faster, cost less, and are more secure
- Choose between
node:20,-slim,-alpine, and distroless bases for the job - Reduce layers by combining
RUNcommands and cleaning caches in the same step - Install production-only dependencies with
npm ci --omit=devand prune caches - Run as a non-root
USERand add aHEALTHCHECK - Measure image size and waste with
docker historyanddive
Estimated Time: 70 minutes
Practice: Optimize a naive image and measure the size reduction at each step.
In This Lesson
Why Size Matters
An image is not just files on disk â it is something pushed to a registry, pulled onto every node in a cluster, and scanned for vulnerabilities on every build. Size multiplies across all of that.
The analogy is packing for a trip. The unoptimized traveler crams their entire wardrobe, every gadget, and a shelf of books into a giant suitcase "just in case." The optimized traveler packs only versatile essentials for the actual destination â a compact bag that is easy to carry and holds exactly what's needed. An optimized image is that carry-on.
- Faster deploys â a 120 MB image pulls in a fraction of the time of a 1 GB one, so rollouts and autoscaling are quicker.
- Lower cost â registry storage and cross-region transfer scale directly with size.
- Smaller attack surface â every compiler, shell, and unused package is one more thing a scanner flags and an attacker can exploit.
- Faster CI â smaller layers cache and transfer faster, tightening the feedback loop.
Choosing a Base Image
The base image is the single biggest lever on final size. Here is how the common Node bases compare.
| Base image | Approx. size | Best for | Trade-off |
|---|---|---|---|
node:20 | ~1.1 GB | Local dev, build stages | Huge; ships a full Debian toolchain |
node:20-slim | ~220 MB | General runtime | Trimmed Debian; may miss some build deps |
node:20-alpine | ~135 MB | Most production runtimes | musl libc; a few native modules need extra build tools |
gcr.io/distroless/nodejs20 | ~110 MB | Security-focused prod | No shell or package manager â harder to debug |
Alpine: the pragmatic default
Alpine Linux swaps Debian's glibc for the smaller musl C library, cutting the OS footprint dramatically. It is the go-to production base for most Node apps. The one gotcha: packages with native C++ addons occasionally need build tools during install.
# Install native build deps, compile, then remove them â all in one layer
RUN apk add --no-cache --virtual .build python3 make g++ && \
npm ci --omit=dev && \
apk del .build
The --virtual .build trick groups the temporary packages under a label so apk del .build removes them cleanly once the compile is done.
ð Distroless: for when there is no shell to attack
Google's distroless images contain your app and the language runtime â and nothing else. No shell, no package manager, no curl. That is superb for security (there is little for an intruder to use) but means you cannot docker exec into a shell to poke around. Reach for it once your app is stable and you value the hardened surface over debug convenience.
Fewer, Leaner Layers
Recall from the Dockerfile lesson that each RUN adds a permanent layer. Crucially, deleting a file in a later layer does not shrink the image â the bytes still exist in the earlier layer beneath. So cleanup must happen in the same RUN that created the mess.
â Cleanup in a separate layer does nothing
RUN apt-get update
RUN apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/* # too late â files persist in earlier layers
â Combine and clean in one layer
RUN apt-get update && \
apt-get install -y --no-install-recommends build-essential && \
rm -rf /var/lib/apt/lists/*
The same idea applies to npm: run the install and clean its cache together.
RUN npm ci --omit=dev && npm cache clean --force
RUN is what actually shrinks the image.Pruning Dependencies
For most Node apps, node_modules is the heaviest thing in the image. Two habits keep it lean:
1. Install production dependencies only
# npm ci is reproducible (uses package-lock.json); --omit=dev drops devDependencies
RUN npm ci --omit=dev
Your test frameworks, linters, TypeScript compiler, and bundler live in devDependencies. They are essential to build the app and useless to run it. In a multi-stage build you compile in the build stage (with all deps) and install only production deps in the runtime stage.
2. Prune the npm cache
RUN npm ci --omit=dev && npm cache clean --force
ðĄ npm ci vs npm install
npm install can update package-lock.json and resolve versions on the fly. npm ci installs exactly what the lockfile pins, fails if the lockfile is out of sync, and is faster in CI. For images you want the deterministic one: npm ci.
A thorough .dockerignore also keeps dependency and build junk out of the context entirely:
# .dockerignore
node_modules
.git
.env
.env.*
coverage
dist
build
*.md
*.log
.vscode
__tests__
Security & Health
A small image should also be a safe, observable one. Two low-effort additions pay off in production.
Run as a non-root user
If your base does not already provide one (Alpine's minimal image does not include the node user in every variant), create one and drop to it:
# Create a dedicated non-root user and give it the app directory
RUN addgroup -S nodejs && adduser -S -G nodejs nodejs
WORKDIR /app
COPY --chown=nodejs:nodejs . .
USER nodejs
CMD ["node", "index.js"]
If a process running as root is compromised, the attacker has root inside the container. Running as an unprivileged user contains the blast radius.
Add a health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node healthcheck.js
Docker and orchestrators use the HEALTHCHECK exit code to decide whether a container is actually serving traffic, not merely running. Keep the probe lightweight and have it verify real functionality â for example, that the HTTP server answers â rather than just that the process exists.
â ïļ Scan before you ship
Small does not automatically mean safe. Run a vulnerability scanner as part of CI:
docker scout cves myapp:1.0 # built into Docker Desktop
trivy image myapp:1.0 # popular open-source scanner
Measuring & Analyzing
You cannot optimize what you do not measure. Two commands tell you where the bytes are.
docker history â bytes per layer
docker history --no-trunc myapp:1.0
This lists every layer with its size, so you can spot the expensive instruction â usually the dependency install â at a glance.
dive â an interactive layer explorer
dive opens an image layer by layer, shows exactly which files each one adds, and reports "wasted space" from files added in one layer and deleted in another.
Output
$ dive myapp:1.0
Layers Size
FROM node:20-alpine 5.0 MB
COPY package*.json ./ 124 KB
RUN npm ci --omit=dev 42.5 MB
COPY . . 3.2 MB
Image efficiency: 96%
Wasted space: 1.8 MB
Tip: a stray npm cache is inflating the deps layer.
Track these numbers over time: final image size, layer count, and vulnerability count. A trend line makes regressions obvious the moment a careless change doubles your image.
A Fully Optimized Image
Here is every technique from this lesson combined into one production Dockerfile for a Node.js API â multi-stage, Alpine runtime, prod-only deps, non-root user, and a health check.
# ================= Build stage =================
FROM node:20.11 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ============ Production deps stage ============
FROM node:20.11-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
# ================ Runtime stage ================
FROM node:20.11-alpine
WORKDIR /app
ENV NODE_ENV=production
# Non-root user
RUN addgroup -S nodejs && adduser -S -G nodejs nodejs
# Slim, production-only node_modules + compiled output
COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=build --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json ./
USER nodejs
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD node healthcheck.js
EXPOSE 3000
CMD ["node", "dist/index.js"]
â The payoff
A naive FROM node:20 + COPY . . + npm install image for this app lands around 950 MB. The version above comes in near 135 MB â roughly a 7× reduction â while also running as non-root and reporting its health.
Practice & Quiz
ðïļ Exercise 1: Slim a naive image
Goal: Optimize this image for size. Switch to a slim base, order layers for caching, install prod-only deps, and run as non-root.
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "index.js"]
ðĄ Hint
Use node:20-alpine, copy package*.json first, run npm ci --omit=dev && npm cache clean --force, then COPY . ., and add a non-root USER.
â Solution
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
USER node
EXPOSE 3000
CMD ["node", "index.js"]
Pair with a .dockerignore excluding node_modules, .git, and .env. For a build step, split it into a multi-stage build as in the previous lesson.
ðïļ Exercise 2: Find the waste
Goal: You build an image and it is 90 MB larger than expected. Describe the two commands you would run to locate the bloat, and one common cause.
â Solution
Run docker history --no-trunc myapp:tag to see per-layer sizes and spot the heavy instruction, then dive myapp:tag to inspect which files inflate it and how much space is wasted. A common cause is an un-cleaned npm cache or a stray node_modules copied in because it was missing from .dockerignore.
ðŊ Quick Quiz
Question 1: Why must you clean caches in the same RUN that created them?
Question 2: Which flag installs production-only dependencies?
Question 3: What is the main trade-off of a distroless base?
Best Practices & Pitfalls
â Do
- Use the smallest base that meets your needs â
alpinefor most, distroless for hardened prod - Combine related
RUNcommands and clean caches in the same step - Install production-only deps (
npm ci --omit=dev) in the runtime stage - Run as a non-root
USERand add a lightweightHEALTHCHECK - Measure with
docker historyanddive; scan with Docker Scout or Trivy
â Don't
- Delete files in a later layer expecting the image to shrink
- Ship devDependencies, test files, or source maps to production
- Use temporary build tools without removing them (use Alpine's
--virtual) - Assume small equals secure â always scan for known CVEs
ðĄ Optimize in order of impact
Biggest wins first: pick a slim base and use a multi-stage build, then prune to production deps, then combine layers and clean caches, then add security hardening. Chasing the last kilobyte before the first megabyte is wasted effort.
Summary
ð Key Takeaways
- Small images deploy faster, cost less, and are more secure
- The base image is the biggest lever â reach for
alpine, escalate to distroless for hardened prod - Combine
RUNcommands and clean caches in the same layer, or the bytes stay trapped below - Install production-only dependencies with
npm ci --omit=dev - Harden with a non-root
USERandHEALTHCHECK; verify withdiveand a CVE scanner
ð Additional Resources
- Docker Docs â Building best practices
- Docker Docs â Docker Scout (image analysis)
- GoogleContainerTools â distroless base images
ð What's Next?
You can now build lean, secure single images. But real apps are several services at once â an API, a database, a cache. The next lesson, Docker Compose basics, shows how to define and run a whole multi-container stack from one file.
ð Lean, mean, and shippable!
Your images are now production-grade. Next we wire several of them together.