Skip to main content

ðŸŠķ 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 RUN commands and cleaning caches in the same step
  • Install production-only dependencies with npm ci --omit=dev and prune caches
  • Run as a non-root USER and add a HEALTHCHECK
  • Measure image size and waste with docker history and dive

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.
graph TD A["Image optimization"] --> B["Smaller base"] A --> C["Fewer layers"] A --> D["Prune dependencies"] A --> E["Harden security"] B --> B1["alpine, slim, distroless"] C --> C1["Combine RUN, clean in-step"] D --> D1["omit=dev, cache clean"] E --> E1["non-root, scan, healthcheck"]

Choosing a Base Image

The base image is the single biggest lever on final size. Here is how the common Node bases compare.

Base imageApprox. sizeBest forTrade-off
node:20~1.1 GBLocal dev, build stagesHuge; ships a full Debian toolchain
node:20-slim~220 MBGeneral runtimeTrimmed Debian; may miss some build deps
node:20-alpine~135 MBMost production runtimesmusl libc; a few native modules need extra build tools
gcr.io/distroless/nodejs20~110 MBSecurity-focused prodNo 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
Two layer stacks: separate cleanup leaves waste trapped below, combined cleanup keeps the layer small Separate RUNs install (bloat kept) rm (no effect) Bytes still stored below ⚠ïļ One RUN install && rm Only the clean result is stored ✅
Deleting in a separate layer leaves the bytes trapped in the layer below. Cleaning inside the same 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 — alpine for most, distroless for hardened prod
  • Combine related RUN commands and clean caches in the same step
  • Install production-only deps (npm ci --omit=dev) in the runtime stage
  • Run as a non-root USER and add a lightweight HEALTHCHECK
  • Measure with docker history and dive; 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 RUN commands 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 USER and HEALTHCHECK; verify with dive and a CVE scanner

📚 Additional Resources

🚀 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.