Default Spring Boot Docker images are bloated and slow to build. Layered JARs, distroless bases, and BuildKit caching cut image size by 80% and CI build time in half.
JOptimize Team
The default approach to Dockerizing a Spring Boot application — copy the JAR, run it — produces an image that's 300-500MB, rebuilds from scratch on every code change, and takes 3-5 minutes to push through CI. For a microservices system with 10 services, this is a real bottleneck. Every code change means waiting 30-50 minutes for all images to build and push.
The good news is that fixing this requires about 30 minutes of work and produces permanent improvements. Here's what to do.
The naive Dockerfile copies the Spring Boot fat JAR as a single layer:
# Naive approach — the entire 80MB JAR is one layer FROM eclipse-temurin:21-jre-alpine COPY target/myapp.jar app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]
This looks simple, but it has a critical flaw: every code change — even changing one line of business logic — invalidates the entire JAR layer and forces Docker to upload 80MB to the registry. Your 200KB of changed bytecode costs 80MB of network transfer every time.
The reason is how Spring Boot fat JARs work. They bundle your code, all your dependencies (Spring Framework, Hibernate, Jackson, etc.), and the Spring Boot launcher into a single ZIP-format file. Dependencies are typically 70-75MB; your code is 5-10MB. But they're all in one layer.
Spring Boot 2.3+ supports layered JARs, which extract the fat JAR into separate layers ordered by how frequently they change:
# Multi-stage layered build FROM eclipse-temurin:21-jre-alpine AS builder WORKDIR /app COPY target/*.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract FROM eclipse-temurin:21-jre-alpine WORKDIR /app # Copy layers from most stable to most volatile # Docker caches each COPY as a separate layer COPY /app/dependencies/ ./ COPY /app/spring-boot-loader/ ./ COPY /app/snapshot-dependencies/ ./ COPY /app/application/ ./ EXPOSE 8080 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
With this approach, when you change business logic:
dependencies layer (70MB) is cached — Docker doesn't re-upload itapplication layer (5MB) is rebuilt and pushedCI build time drops from 5 minutes to under 1 minute for incremental changes.
The base JRE image choice has a massive impact on final image size:
| Base Image | Size | Notes |
|---|---|---|
eclipse-temurin:21-jdk | ~450MB | Full JDK — never use for runtime |
eclipse-temurin:21-jre | ~280MB | Full JRE — common default |
eclipse-temurin:21-jre-alpine | ~185MB | Alpine — good balance |
gcr.io/distroless/java21 | ~80MB | No shell, no OS tools — most secure |
| GraalVM native binary | ~120MB | No JVM at all |
Distroless images from Google contain only the JRE and its runtime dependencies — no shell, no package manager, no OS utilities. This makes them significantly smaller and dramatically more secure (no shell means no shell injection attacks, smaller attack surface for CVE scanners).
FROM eclipse-temurin:21-jre-alpine AS builder WORKDIR /app COPY target/*.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract # Distroless runtime — no shell, minimal attack surface FROM gcr.io/distroless/java21-debian12 WORKDIR /app COPY /app/dependencies/ ./ COPY /app/spring-boot-loader/ ./ COPY /app/snapshot-dependencies/ ./ COPY /app/application/ ./ USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
One trade-off: with distroless, you can't docker exec into a container and run bash. For debugging in production, use kubectl debug with an ephemeral container instead.
Jib (from Google) builds Docker images directly from Maven/Gradle without a Dockerfile, a local Docker daemon, or any Docker knowledge:
<plugin> <groupId>com.google.cloud.tools</groupId> <artifactId>jib-maven-plugin</artifactId> <version>3.4.2</version> <configuration> <from> <image>eclipse-temurin:21-jre-alpine</image> </from> <to> <image>myregistry/myapp:${project.version}</image> </to> <container> <jvmFlags> <jvmFlag>-XX:+UseContainerSupport</jvmFlag> <jvmFlag>-XX:MaxRAMPercentage=75.0</jvmFlag> <jvmFlag>-XX:+ExitOnOutOfMemoryError</jvmFlag> </jvmFlags> <ports><port>8080</port></ports> <creationTime>USE_CURRENT_TIMESTAMP</creationTime> </container> </configuration> </plugin>
# Build and push directly to registry — no Docker needed mvn jib:build # Build to local Docker daemon mvn jib:dockerBuild
Jib automatically creates layered images and handles registry authentication. It's particularly useful in CI environments where Docker-in-Docker is awkward.
Always include these JVM flags in your Docker configuration:
ENV JAVA_TOOL_OPTIONS="\ -XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -XX:InitialRAMPercentage=50.0 \ -XX:MaxMetaspaceSize=256m \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/tmp/heapdump.hprof \ -XX:+ExitOnOutOfMemoryError \ -Djava.security.egd=file:/dev/./urandom"
-XX:+UseContainerSupport tells the JVM to respect container memory limits rather than reading host machine memory. Without it, a JVM in a 1GB container pod might try to allocate a 12GB heap based on the 64GB host machine.
-Djava.security.egd=file:/dev/./urandom prevents startup hangs caused by SecureRandom blocking on /dev/random entropy in containerized environments.
# .github/workflows/build.yml - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: . push: true tags: myregistry/myapp:${{ github.sha }} cache-from: type=gha # GitHub Actions cache cache-to: type=gha,mode=max # Store all layers in cache build-args: | BUILDKIT_INLINE_CACHE=1
With BuildKit caching, the dependencies layer (70MB) is cached in GitHub Actions between builds. Only the application layer (5MB) is rebuilt. Total CI time drops from 5 minutes to 45 seconds for code-only changes.
USER nonroot:nonroot or create a dedicated user; running as root in a container is a security risk.dockerignore to exclude target/, .git/, IDE files; otherwise build context is hugeMaxRAMPercentage, the JVM ignores the container limit and can be OOM-killedOptimizing Spring Boot Docker images comes down to three changes: use layered JARs so only your code layer (5MB) is rebuilt on each commit, choose a smaller base image (distroless or Alpine), and configure proper JVM flags for containers. The result is an 80MB image that rebuilds in 45 seconds instead of a 500MB image that takes 5 minutes. For a CI pipeline running 20 times a day, this saves hours of developer waiting time.
A smaller, faster Docker image is great. But if your app has N+1 queries and missing indexes, it'll be slow regardless of the container. JOptimize analyzes your Spring Boot code for the issues that matter at runtime.
Ship small. Ship fast. Ship correct.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.