Codecondo

Docker Multi-Stage Builds: 7 effective Ways to Slash Image Size

Docker multi-stage builds

INTRODUCTION

Docker multi-stage builds solve one of the most frustrating problems in containerized development: bloated images that take forever to push, pull, and deploy. If you’ve ever run docker images and winced at a 900MB image for an app that’s really just 20MB of compiled code, this guide is for you.

Below, we’ll break down exactly how Docker multi-stage builds work, why they outperform traditional single-stage Dockerfiles, and how to apply them across Python, Node.js, Go, and Java projects — complete with working code you can drop into your own pipeline today.

 Why Bloated Docker Images Are a Bigger Problem Than You Think

Most developers write a working Dockerfile and never look back. But an oversized image isn’t just an eyesore — it carries real, compounding costs:

Docker multi-stage builds exist specifically to fix this: build your app in one throwaway environment, and ship only the finished artifact in another.

 A Quick Refresher on Docker Image Layers

Before you can appreciate what Docker multi-stage builds fix, it helps to understand what’s actually happening under the hood. If you’re new to Docker internals, Code Condo offers a detailed explanation of Docker image layers, caching, and image optimization techniques that make it easier to understand why multi-stage builds are so effective.

 What an Image Really Is

A Docker image is a stack of read-only filesystem layers plus instructions on how to run a container from them. A container is just a live process running on top of that stack, with one writable layer added.

 Why Layers Never Really Disappear

Each RUN, COPY, or ADD line in a Dockerfile creates a new, permanent layer. Docker caches these for speed — reordering rarely-changed instructions (like dependency installs) above frequently-changed ones (like source code) keeps builds fast.

Here’s the catch: layers are permanent. Install a compiler in one instruction and delete it three lines later, and its bytes are still baked into an earlier layer forever. This single quirk is the root cause of most oversized images — and it’s precisely what Docker multi-stage builds were designed to eliminate.

 What Docker Multi-Stage Builds Actually Do

A traditional, single-stage Dockerfile crams everything into one image: compilers, dev dependencies, build artifacts, and the final runtime — all riding along together, forever.

Docker multi-stage builds change that by allowing multiple FROM instructions in a single Dockerfile. Each FROM kicks off an independent stage. You get a heavyweight “builder” stage loaded with everything needed to compile your code, and a separate, lean “runtime” stage that contains only what the app needs to actually run.

The instruction doing the heavy lifting is COPY --from=<stage>, which reaches into a previous stage and pulls out only the specific files you name — a binary, a dist/ folder, a .jar — while everything else gets discarded along with the builder stage. As highlighted in Code Condo, understanding how multi-stage builds separate the build environment from the runtime environment is one of the most effective ways to create smaller, more secure, and production-ready Docker images.

 Before and After: A Node.js Example

Single-stage (the problem):

FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

This drags TypeScript, dev tooling, source files, and npm’s full cache into the final image — none of which the running app ever touches.

Multi-stage (the fix):

# Stage 1: builder
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: runtime
FROM node:20-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]

Only the compiled dist/ folder and production dependencies survive into the final image. TypeScript, test files, and dev caches never leave the builder stage.

 Docker Multi-Stage Builds in Python

Without multi-stage:

FROM python:3.12
WORKDIR /app
RUN apt-get update && apt-get install -y gcc
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "main.py"]

GCC gets installed to compile native extensions, then sits unused in the image forever.

With multi-stage:

FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt

FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]

Swapping the ~900MB python:3.12 base for the ~45MB slim variant, and leaving GCC behind entirely, typically takes a Python API from around 950MB down to roughly 150–180MB.

 Go: The Best-Case Scenario for Multi-Stage Builds

Go compiles to a single static binary with zero runtime dependencies, which makes it an ideal candidate:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

FROM scratch AS runtime
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]

scratch contains literally nothing — no shell, no OS, no package manager. Paired with Docker multi-stage builds, a complete Go service can ship at 5–10MB. Need to debug inside the container? Swap scratch for alpine and add a couple of megabytes back.

 Java (Spring Boot): Trading a Full JDK for a Slim JRE

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests

FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]

Maven and the full JDK — often 600MB combined — never make it past the builder stage. The runtime only needs the far smaller JRE plus a single .jar.

 Measuring the Improvement Yourself

Don’t take these numbers on faith — verify them locally:

docker build -t myapp:single-stage -f Dockerfile.old .
docker build -t myapp:multi-stage -f Dockerfile.new .
docker images | grep myapp

Beyond raw size, also track build time (cold vs. cached), docker pull speed in CI, and pod startup time during Kubernetes autoscaling events. Teams that switch to Docker multi-stage builds commonly report a 60–85% drop in final image size.

 Best Practices for Docker Multi-Stage Builds

.git
node_modules
dist
*.md
.env
tests

 Advanced Patterns Worth Knowing

Docker multi-stage builds support more than a simple build-then-ship flow:

FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM base AS test
COPY . .
RUN npm test

FROM base AS builder
COPY . .
RUN npm run build

FROM node:20-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]

Run docker build --target=test . to execute just the test stage without ever building the production image — useful for isolating CI jobs.

 Security Wins That Come Free With Multi-Stage Builds

A smaller image isn’t only faster — it’s meaningfully harder to attack. Docker multi-stage builds strip out compilers, package managers, and unused libraries entirely, which means fewer CVEs surfacing in tools like Trivy, Grype, or Docker Scout, and a much smaller pivot point if the application itself is ever compromised.

 Mistakes That Undermine Multi-Stage Builds

 Where This Pattern Pays Off Most

Docker multi-stage builds deliver the biggest returns in microservices architectures, Kubernetes deployments, serverless containers where cold-start time matters, CI/CD pipelines running on every commit, and any production API where a smaller footprint reduces both risk and resource cost.

 The Bottom Line

Docker multi-stage builds remain one of the highest-impact, lowest-effort changes you can make to a Dockerfile. Separate what you need to build your app from what you need to run it, copy across only the finished artifact, and the size reduction follows almost automatically — typically 60–80% on the very first attempt, before any other optimization.

If your Dockerfiles are still single-stage, this is worth fixing this week. Split the build and runtime concerns apart, run docker images before and after, and see the difference for yourself.

Read more : Explore more Docker, DevOps, and cloud computing tutorials on Eduonix to deepen your containerization skills and stay updated with modern development best practices.

Exit mobile version