Tuesday, October 27, 2026

Smaller, Safer Docker Images for Spring Boot with Multi-Stage Builds

The first Docker image I built for a Spring Boot service was 780 MB: a full JDK, Maven, the source code and a fat JAR, all running as root. It worked, but every deploy pushed hundreds of megabytes and every scan found dozens of CVEs in tools the app never used. A multi-stage build fixes all of that.

The Dockerfile

# ---- 1. build ----
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B -q dependency:go-offline
COPY src ./src
RUN mvn -B -q package -DskipTests

# ---- 2. split the jar into layers ----
FROM eclipse-temurin:21-jre AS extract
WORKDIR /app
COPY --from=build /src/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted

# ---- 3. runtime ----
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 10001 appuser
WORKDIR /app
COPY --from=extract /app/extracted/dependencies/ ./
COPY --from=extract /app/extracted/spring-boot-loader/ ./
COPY --from=extract /app/extracted/snapshot-dependencies/ ./
COPY --from=extract /app/extracted/application/ ./
USER 10001
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Why each piece matters

  • Build stage is thrown away. Maven, the JDK and your source never reach the final image.
  • dependency:go-offline before COPY src. Dependencies get their own cached layer, so a code-only change does not re-download half of Maven Central.
  • Layered JAR. Spring Boot splits the app into dependencies, loader and application layers. Only the small application layer changes between releases, so pushes and pulls are fast. (The -Djarmode=tools syntax is Spring Boot 3.3+; older versions use -Djarmode=layertools -jar app.jar extract.)
  • JRE, not JDK. Smaller image and fewer packages for the scanner to flag. Distroless or Alpine-based JREs go smaller still if your team is comfortable debugging without a shell.
  • Non-root user. Many Kubernetes clusters enforce runAsNonRoot; set it in the image so it just works.
  • MaxRAMPercentage sizes the heap from the container memory limit instead of a hard-coded -Xmx.

Don't forget .dockerignore

target/
.git/
.idea/
*.iml
**/*.log

Without it, COPY sends your whole working tree (including .git) to the build context.

Result

For a typical service the image dropped from about 780 MB to under 250 MB, rebuilds after a code change take seconds, and the image scan in CI went from dozens of findings to a handful that actually belong to the runtime. Run trivy image on both versions and compare for yourself.

No comments: