Contents

The Dockerfile below defines the container image build steps. Each instruction is annotated to explain its purpose.

# ❌ Single-stage — ships full JDK, build tools, source code FROM eclipse-temurin:21-jdk WORKDIR /app COPY . . RUN ./mvnw package -DskipTests EXPOSE 8080 CMD ["java", "-jar", "target/app.jar"] # Resulting image: ~700 MB

Problems: includes the full JDK, Maven, source code, and test dependencies — none of which are needed at runtime.

Stage 1 compiles and packages. Stage 2 is the lean runtime image — only the JAR and a JRE are copied across.

# Stage 1 — Build FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /app # Copy only dependency specs first — exploits layer caching COPY pom.xml . COPY .mvn/ .mvn/ COPY mvnw . RUN ./mvnw dependency:go-offline -q # Now copy source and build COPY src/ src/ RUN ./mvnw package -DskipTests -q # Stage 2 — Runtime FROM eclipse-temurin:21-jre-alpine AS runtime WORKDIR /app # Copy only the built JAR from the builder stage COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] # Resulting image: ~220 MB

jlink creates a minimal JRE containing only the modules your application needs. Use jdeps to discover which modules the JAR requires.

# Stage 1 — Build the fat JAR FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /app COPY pom.xml . && COPY .mvn/ .mvn/ && COPY mvnw . RUN ./mvnw dependency:go-offline -q COPY src/ src/ RUN ./mvnw package -DskipTests -q # Stage 2 — Create minimal JRE with jlink FROM eclipse-temurin:21-jdk-alpine AS jre-builder WORKDIR /jre-build # Discover required modules from the fat JAR COPY --from=builder /app/target/*.jar app.jar RUN jdeps --ignore-missing-deps \ --print-module-deps \ --multi-release 21 \ --recursive \ --class-path 'BOOT-INF/lib/*' \ app.jar > modules.txt && \ cat modules.txt # e.g., java.base,java.logging,java.net.http,... # Build custom JRE — add java.security.jgss,jdk.crypto.ec for HTTPS RUN jlink \ --add-modules $(cat modules.txt),java.security.jgss,jdk.crypto.ec \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress=2 \ --output /custom-jre # Stage 3 — Final minimal image FROM alpine:3.19 AS runtime WORKDIR /app # Copy custom JRE and app COPY --from=jre-builder /custom-jre /opt/jre COPY --from=builder /app/target/*.jar app.jar ENV JAVA_HOME=/opt/jre ENV PATH="${JAVA_HOME}/bin:${PATH}" EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"] # Resulting image: ~85 MB

Spring Boot's layered JAR feature splits the fat JAR into dependency, snapshot-dependencies, spring-boot-loader, and application layers. Only changed layers are rebuilt and re-pulled.

org.springframework.boot spring-boot-maven-plugin true FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /app COPY pom.xml . && COPY .mvn/ .mvn/ && COPY mvnw . RUN ./mvnw dependency:go-offline -q COPY src/ src/ RUN ./mvnw package -DskipTests -q # Extract layers from the layered JAR FROM eclipse-temurin:21-jre-alpine AS layer-extractor WORKDIR /app COPY --from=builder /app/target/*.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract # Final runtime — copy layers in order of change frequency (most stable first) FROM eclipse-temurin:21-jre-alpine AS runtime WORKDIR /app COPY --from=layer-extractor /app/dependencies/ ./ COPY --from=layer-extractor /app/spring-boot-loader/ ./ COPY --from=layer-extractor /app/snapshot-dependencies/ ./ COPY --from=layer-extractor /app/application/ ./ EXPOSE 8080 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] With layered JARs, rebuilding after a code-only change copies only the application layer (a few KB) — not the 200+ MB dependency layer. This reduces CI build time from minutes to seconds for incremental changes.

The Dockerfile below defines the container image build steps. Each instruction is annotated to explain its purpose.

FROM eclipse-temurin:21-jre-alpine AS runtime WORKDIR /app # Create a non-root user and group RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY --from=builder /app/target/*.jar app.jar # Change ownership RUN chown appuser:appgroup app.jar # Drop to non-root USER appuser # Read-only filesystem — app writes to /tmp only VOLUME ["/tmp"] EXPOSE 8080 ENTRYPOINT ["java", \ "-XX:MaxRAMPercentage=75.0", \ "-XX:+UseContainerSupport", \ "-Djava.security.egd=file:/dev/./urandom", \ "-jar", "app.jar"]

Cloud Native Buildpacks produce OCI-compliant images without writing a Dockerfile. Spring Boot's Maven and Gradle plugins include built-in support.

# Build image with Buildpacks (requires Docker running) ./mvnw spring-boot:build-image \ -Dspring-boot.build-image.imageName=myorg/payment-service:latest # Gradle ./gradlew bootBuildImage --imageName=myorg/payment-service:latest org.springframework.boot spring-boot-maven-plugin myorg/${project.artifactId}:${project.version} paketobuildpacks/builder-jammy-tiny 21 -XX:MaxRAMPercentage=75

Google's distroless images contain only the application and its runtime dependencies — no shell, no package manager. This dramatically reduces the attack surface.

FROM eclipse-temurin:21-jdk-alpine AS builder WORKDIR /app COPY pom.xml . && COPY .mvn/ .mvn/ && COPY mvnw . RUN ./mvnw dependency:go-offline -q COPY src/ src/ RUN ./mvnw package -DskipTests -q # Distroless Java 21 — no shell, no apt, minimal OS FROM gcr.io/distroless/java21-debian12:nonroot AS runtime WORKDIR /app COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 CMD ["app.jar"] # ~130 MB, non-root by default, no shell attack surface

The output or file structure below illustrates the expected result.

# .dockerignore — exclude everything not needed for the build target/ .git/ .github/ *.md *.log .idea/ *.iml .DS_Store node_modules/ **/*.class **/*.jar !target/*.jar # exception — include the built JAR if copying from host

A small build context means faster docker build — the daemon doesn't need to transfer gigabytes of compiled class files and IDE metadata.

ApproachBase ImageApprox SizeNotes
Naive single-stageeclipse-temurin:21-jdk~700 MBFull JDK + build tools
Multi-stageeclipse-temurin:21-jre-alpine~220 MBJRE only, no source
Layered JAReclipse-temurin:21-jre-alpine~220 MBSame size, faster incremental rebuild
Distrolessdistroless/java21-debian12~130 MBNo shell, minimal OS
jlink custom JREalpine:3.19~85 MBSmallest — only needed modules
Buildpacks (tiny)paketobuildpacks/run-jammy-tiny~90 MBNo Dockerfile needed, reproducible
GraalVM nativedistroless/static-debian12~60 MBAOT compiled, instant startup