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.
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
~/.m2cache. ~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
.javafiles. - 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.managementwasn'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
| Approach | Size | Build time | Notes |
|---|---|---|---|
| Single-stage JDK | 1.24 GB | 2m 40s | Ship build tools to prod. No. |
| Multi-stage JRE-alpine | 187 MB | 2m 10s | Reasonable default. |
| Multi-stage distroless | 80 MB | 2m 12s | Best size/risk trade. |
| Custom jlink runtime | 62 MB | 3m 30s | Don'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 nameline; 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 appor distroless's:nonroottag.) - Don't reach for
jlinkuntil 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.