Java images are often the largest and slowest in a fleet, but they do not have to be. This lesson builds a Spring Boot 3 service into a compact image using a multi-stage build, Maven dependency caching, layered jars and JVM flags that respect container memory limits. After it you will produce a JVM image that rebuilds in seconds when only application code changes.
The build stage uses a full JDK image and the project's Maven wrapper, so the build does not depend on a Maven installed on the host. The critical detail is order: copy pom.xml first and resolve dependencies before copying src, so the dependency download is cached until the POM changes.
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q dependency:go-offline
COPY src src
RUN --mount=type=cache,target=/root/.m2 ./mvnw -B -q package -DskipTestsThe cache mount on /root/.m2 keeps the local Maven repository between builds without adding it to any image layer. For Gradle, copy gradlew and the build files first, cache /root/.gradle, and run ./gradlew bootJar --no-daemon. Tests are skipped here because CI runs them as a separate step before the image build.
A Spring Boot fat jar is a single 50 MB file, so any code change replaces the whole layer and the whole 50 MB must be pushed and pulled again. Since Spring Boot 3.3, the jar can extract itself into layers ordered by change frequency:
java -Djarmode=tools -jar target/app.jar extract --layers --destination extracted
ls extracted
# application dependencies snapshot-dependencies spring-boot-loaderCopying those directories into the runtime image in that order (as in the sample code) means dependencies land in an early, rarely changing layer while your classes sit in the last one. A rebuild after editing a controller pushes only the small application layer. Spring Boot 2.3 to 3.2 use -Djarmode=layertools -jar app.jar extract with the same directory names.
| Image | Approximate size | Use |
|-------|------------------|-----|
| eclipse-temurin:21-jdk | 450 MB | Build stage only |
| eclipse-temurin:21-jre | 270 MB | Runtime, Ubuntu-based |
| eclipse-temurin:21-jre-alpine | 180 MB | Runtime, smallest of the official Temurin tags |
A JRE has everything needed to run compiled classes and nothing to compile them. The runtime stage also creates a non-root user and uses ENTRYPOINT in exec form so the JVM receives SIGTERM and Spring's graceful shutdown (server.shutdown=graceful) can finish in-flight requests.
Modern JVMs (11 and later) are container-aware: they read the cgroup limit rather than the host's total memory. By default the maximum heap is only 25% of that limit, which wastes most of the memory you allocate. Raise it, and set the flag through JAVA_TOOL_OPTIONS so it applies without changing the entrypoint:
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"docker run --rm -m 1g shop-api:1.0 java -XX:+PrintFlagsFinal -version | grep MaxHeapSize
# MaxHeapSize = 805306368 (768 MB, 75% of 1 GB)Leave headroom for metaspace, threads and off-heap buffers; 75% is a safe default. -XX:+ExitOnOutOfMemoryError makes the JVM exit on an OOM so the restart policy can replace it instead of leaving a half-dead process.
Add Spring Boot Actuator and point the healthcheck at it; the JVM needs a generous start_period:
services:
api:
build: .
ports: ["8080:8080"]
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
interval: 15s
start_period: 40s
depends_on:
db:
condition: service_healthySpring's relaxed binding turns SPRING_DATASOURCE_URL into spring.datasource.url, so configuration works through plain environment variables. If you prefer not to maintain a Dockerfile at all, ./mvnw spring-boot:build-image uses Cloud Native Buildpacks to produce a layered, non-root image with tuned JVM flags automatically.
src before resolving dependencies, so every build re-downloads Maven artifacts.start_period so a slow-starting JVM is marked unhealthy.Why extract a Spring Boot jar into layers before copying it into the runtime image?
pom.xml and resolve dependencies before copying src, with a cache mount on /root/.m2.java -Djarmode=tools -jar app.jar extract --layers and copy the layers in order.-XX:MaxRAMPercentage=75.0 via JAVA_TOOL_OPTIONS; the JVM is container-aware but defaults to a 25% heap.start_period, or let Buildpacks produce the image for you.Next lesson: Serving Static Sites and SPAs with Nginx — package HTML, CSS and single-page apps into tiny web server images.