RizTech Academy logo
RizTech Academy
ContainersLesson 4 of 630 min

Multi-stage builds and keeping images small

A working image is not the same as a good image. A naive Dockerfile can produce an image many times larger than it needs to be — full of build tools, dev dependencies, and cruft — and a bloated image is slower to build, slower to push and pull, slower to deploy, and has a larger attack surface. Multi-stage builds and a few habits cut images down dramatically. This lesson is keeping images small, and why it matters more than it first appears.

Why image size matters

It is tempting to ignore size — the image works, who cares if it is 1 GB? You care, for concrete reasons:

  • Deployment speed. Every deploy pushes the image to a registry and pulls it onto servers. A 1 GB image is slow to transfer; a 100 MB one is quick. Multiplied across many deploys and many servers (and autoscaling, where new instances pull the image to start), size directly affects how fast you can ship and scale.
  • Cost. Registry storage and data transfer cost money; large images cost more, and the deployment-storage problem is real (you may have seen it).
  • Security. A smaller image contains less — fewer packages, fewer tools, fewer libraries — which means a smaller attack surface and fewer vulnerabilities to patch. A build toolchain shipped into production is both waste and risk.
  • Startup. Smaller images pull and start faster, which matters for scaling and recovery.

Small images are faster, cheaper and safer. It is worth the small effort to get them.

Start from a small base

The biggest single lever is the base image. node:20 is a full Debian-based image (~1 GB); node:20-alpine is the same Node on tiny Alpine Linux (~130 MB); distroless and slim variants go smaller still. Choosing -alpine over the default often cuts hundreds of megabytes for free — which is why the greetings Dockerfile uses node:20-alpine. (Alpine occasionally causes issues with libraries that expect glibc; when that bites, slim is a good middle ground. But start small and only go bigger if forced.)

Multi-stage builds: the big idea

The most powerful technique is the multi-stage build. The insight: the things you need to build an app (compilers, dev dependencies, build tools) are not the things you need to run it. A multi-stage build uses one stage to build and a separate, clean stage for the final image — copying only the finished artifacts across, and throwing the build tooling away.

Here is the greetings app's real multi-stage Dockerfile:

# ---- Stage 1: install production dependencies ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev

# ---- Stage 2: the runtime image ----
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules   # copy ONLY the installed deps across
COPY package.json ./
COPY src ./src
USER node          # run as the non-root 'node' user
EXPOSE 3000
CMD ["node", "src/server.js"]

The key line is COPY --from=deps ... — the final runtime image starts fresh and pulls in only the installed node_modules from the deps stage, leaving behind npm's cache and anything else that stage created. For a compiled language the win is huge: the build stage has the whole toolchain (hundreds of MB), and the runtime stage copies out just the compiled binary. For this Node app the win is smaller but the pattern is identical, and it is the standard shape of a production Dockerfile: build in one stage, run from a clean one. The verified greetings runtime image comes out around 200 MB — mostly the Node runtime itself, with nothing extra.

Run as a non-root user

Notice USER node in the runtime stage. By default a container runs as root, which is a security risk: if an attacker breaks into the app, they are root inside the container. Running as a non-root user (Node images ship a node user for exactly this) limits the damage. This is a standard hardening step and costs nothing — add a USER line to every image that can run non-root. It is the container echo of the "don't run as root" rule from the Linux permissions lesson.

More size habits

A few further techniques, in order of impact:

  • Combine and clean up RUN steps. Each RUN is a layer; installing packages and cleaning their cache in the same RUN (apt-get install ... && rm -rf /var/lib/apt/lists/*) avoids leaving the cache in a layer.
  • Install only production dependencies (npm ci --omit=dev) — no test frameworks or build tools in the runtime image.
  • Use .dockerignore (the previous lesson) so host junk never enters the image.
  • Inspect what you built: docker images shows sizes; docker history <image> shows each layer's size, so you can see what is making an image big and fix the worst offender.

The workflow is: build, run docker images to see the size, and if it is bigger than expected, docker history to find the fat layer. Treat image size as something you check, the way you check build success — and multi-stage + a small base + non-root will handle the large majority of it.

Check your work

Why size matters: faster deploys/scaling (less to push/pull), lower cost (storage/transfer), better security (smaller attack surface, fewer vulnerabilities — no build toolchain in prod), faster startup. Small = faster, cheaper, safer.

Small base: node:20-alpine (~130 MB) over node:20 (~1 GB); slim/distroless smaller still. Biggest free win; Alpine can need glibc workarounds (use slim if so).

Multi-stage build: build in one stage (toolchain, dev deps), run from a clean stage that COPY --from=... brings only the finished artifacts across — leaving build tooling behind. The standard production shape; the greetings runtime is ~200 MB.

Non-root: containers default to root (a risk); add USER node (or similar) so a compromise isn't root-in-container. Standard, free hardening — the Linux "don't run as root" rule for containers.

More habits: combine+clean RUN steps (install and cache-clean in one layer), production deps only (--omit=dev), .dockerignore, and inspect with docker images (size) / docker history (per-layer). Check size like you check build success.

Practice

  1. Build the greetings image and check its size with docker images; explain what makes up most of it.
  2. Convert a single-stage Dockerfile to multi-stage and explain what the COPY --from line does.
  3. Swap a full base image for its -alpine variant and compare the resulting sizes.
  4. Add a USER line to run as non-root and explain the security benefit.
  5. Use docker history <image> to find the largest layer and describe how you would shrink it.
  6. Explain three concrete reasons a 900 MB image is worse than a 150 MB one in production.

Official documentation

Next: Docker Compose for local development.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship