ML
Docker

Multi-Stage Builds: How I Cut a 1.2 GB Image Down to 80 MB Without Skipping Anything

Multi-stage is the single biggest practical lever in Docker. A walk through a real Spring Boot + Node front-end image, the cuts I made, and why one of them was a mistake.

February 18, 202610 min readDockerBuildPerformance

The image was 1.24 GB. The compiled Java JAR inside it was 38 MB. The Node-built front end was 12 MB. Everything else — 1.19 GB of "everything else" — was build tooling we shipped to production for absolutely no reason.

This is a walkthrough of cutting that image to 80 MB using nothing but multi-stage builds and a couple of opinions. Real numbers, real Dockerfile, no marketing.

1. The single-stage version

FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw -DskipTests package
RUN apt-get update && apt-get install -y nodejs npm
WORKDIR /app/web
RUN npm ci && npm run build
WORKDIR /app
EXPOSE 8080
CMD ["java", "-jar", "target/app.jar"]

Image size: 1.24 GB. What's in it that we don't need to ship?

  • The full JDK. We need a JRE at runtime — half a JDK doesn't run.
  • Maven's ~/.m2 cache. ~400 MB on a typical Spring app.
  • The Node + npm install. We only need the built static files.
  • Source code. We're shipping the JAR, not .java files.
  • apt cache, build tooling, you name it.

2. The multi-stage version

The idea is simple: have multiple FROM blocks, each producing artifacts you can then COPY --from=<stage> into a final, lean image.

# ---- Stage 1: build the front-end
FROM node:20-alpine AS web
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web ./
RUN npm run build

# ---- Stage 2: build the JAR
FROM eclipse-temurin:21-jdk AS api
WORKDIR /app
COPY pom.xml mvnw .mvn ./
COPY .mvn .mvn
RUN ./mvnw -DskipTests dependency:go-offline
COPY src ./src
COPY --from=web /web/dist ./src/main/resources/static
RUN ./mvnw -DskipTests package -B \
 && cp target/*.jar app.jar

# ---- Stage 3: runtime — JRE only
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=api /app/app.jar ./app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Final image: 187 MB. JRE alone is ~170 MB; everything we add is the JAR.

3. The aggressive cut: distroless

If you trust Java's bundled tooling and don't need a shell in production (you don't — that's what kubectl exec into a debug pod is for), distroless gets you under 100 MB:

# ---- Stage 3b: distroless runtime
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
COPY --from=api /app/app.jar ./app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Final image: 80 MB. No shell, no apk, no curl, no bash. The attack surface is approximately your JAR plus the JVM.

4. The cut that was a mistake

I once tried to use jlink to build a custom JRE with only the modules my app needed. Got the image to 62 MB. Felt very smart. Then I shipped it and discovered:

  • Heap dumps no longer worked because jdk.management wasn't in the runtime.
  • HikariCP started flaking because some optional dependency had pulled in a JFR class at runtime, which I'd stripped.
  • Adding modules back kept the image around 75–85 MB anyway.

The lesson: distroless was 90% of the saving with 10% of the operational risk. Custom jlink images are a great hobby project; they're not worth the rollback they will eventually cause.

5. The numbers, side-by-side

ApproachSizeBuild timeNotes
Single-stage JDK1.24 GB2m 40sShip build tools to prod. No.
Multi-stage JRE-alpine187 MB2m 10sReasonable default.
Multi-stage distroless80 MB2m 12sBest size/risk trade.
Custom jlink runtime62 MB3m 30sDon't, unless you must.

6. The accidental superpower: cache stages

Multi-stage isn't only for size. It's also for caching. Notice the dependency:go-offline step — that downloads every Maven dependency before any source is copied. Source changes on every commit; deps change weekly. Splitting them means most builds reuse the dependency layer.

Same trick for Gradle (--write-locks), pip (requirements.txt first), Go (go.mod first). The pattern is: resolve dependencies in a layer that doesn't depend on source. Every CI you maintain will thank you.

Rules of thumb

  • Default to multi-stage. The complexity cost is one FROM ... AS name line; the saving is most of your image.
  • Final stage should be JRE / static / distroless — never JDK / build tools.
  • Run as a non-root user in the final stage. Always. (USER app or distroless's :nonroot tag.)
  • Don't reach for jlink until distroless's size genuinely doesn't work for you.
  • Resolve deps before copying source. The cache hit on a dep-only change saves 10× more time than any other optimisation.

Multi-stage is one of those features that feels almost obvious in retrospect. Then you remember the entire industry shipped JDKs in production for years, and you stop feeling smug about it.

SharePostLinkedIn

Reader Discussion

7 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Highlighted by author
    Arnab Mitra· DevOps · FintechFrom experience

    the COPY-package.json-first trick is one of those things you only learn after you've burned 90 minutes of CI per PR for a month. our build dropped from 4.5min to 38s with that one reorder.

    Feb 20, 2026·2 days later
  2. Fenna Vermeer· Senior SWEStory

    the jlink section made me laugh out loud — we did the EXACT same thing, shaved 18MB, then shipped a heap dump tool that needed jdk.management and woke ourselves up at 3am. distroless really is the sweet spot.

    Feb 23, 2026·5 days later
  3. Ravi Subramanian· Platform EngAsks

    Q — anyone moved to BuildKit cache mounts (RUN --mount=type=cache,target=/root/.m2)? we tested it and it's a step beyond multi-stage for repeatable builds, but the syntax is intimidating to onboard new devs.

    Feb 24, 2026·6 days later
  4. Mei Lin· Full StackFrom experience

    .dockerignore is mandatory not optional — copying that to the wall. our junior shipped an image with .git inside and accidentally exposed our deploy keys. one-line fix, six-month security review.

    Feb 21, 2026·3 days later
  5. Quân Đỗ🇻🇳 HCMC· Backend LeadAgrees

    Distroless thay cho alpine cho Java app là 1 quyết định mà mình tiếc đã không làm sớm. CVE alert giảm 80%, kích thước tương đương alpine, không có shell tức là không có exec attack surface. Highly recommend.

    Feb 22, 2026·4 days later
  6. Rachel Gold· Staff SREAgrees

    the on-call framing throughout this piece is what makes it land. too many infra articles assume you never get paged. those are written by people who never got paged.

    Feb 21, 2026·3 days later
  7. Omar Khalil· Senior SWEKind words

    this is the third article from this blog I've sent to my team this month. you're cooking. don't switch to crypto.

    Feb 23, 2026·5 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email