Java (Spring Boot) #

Java + Spring Boot is the most common stack in enterprise backends. But this combination is also famous for producing very large Docker images — 300-500 MB is a common number in production. Many teams accept this size as “the Java reality”, even though a Spring Boot image can be 70-130 MB with the right strategy.

This article discusses in detail and realistically how to build slim, secure, production-grade Java (Spring Boot) Docker images. We’ll look at why Java images bloat easily, the right strategies to fix it (multi-stage + distroless), and the trade-offs to understand.

1. The Reality of Spring Boot Image Sizes #

Let’s look at realistic production numbers, from the most problematic to the optimal.

SetupImage Size
openjdk:17 (JDK + Debian)300-450 MB
eclipse-temurin:17-jre (JRE only)180-220 MB
eclipse-temurin:17-jre-alpine110-150 MB
Multi-stage + distroless JRE70-130 MB

Insight: The difference between 450 MB and 70 MB is 6x. Optimal Java images aren’t impossible — they just need Dockerfile discipline different from common habits.

A quick audit method:

docker history myapp:latest

Look at which layer is the biggest. Usually the culprit is a JDK (not JRE) in the runtime stage, or unaudited Spring Boot dependencies.

2. Why Java Images Bloat Easily #

JDK vs JRE #

openjdk:* or eclipse-temurin:* images carry the full JDK (Java Development Kit) by default, including javac, jdeps, jlink, and other development tools. For runtime, you only need the JRE (Java Runtime Environment) — a JDK subset containing the JVM and standard libraries.

A JDK in the runtime image = ~200 MB of unused bulk. That’s the first waste to eliminate.

Spring Boot Dependency Bloat #

Spring Boot is famous for being “batteries included” — dependencies that ease development but bloat images. Some starters often causing trouble:

  • spring-boot-starter-data-jpa — pulls Hibernate + JDBC driver + connection pool.
  • spring-boot-starter-security — Spring Security + OAuth + cryptography libraries.
  • spring-boot-starter-web — Spring MVC + embedded Tomcat + Jackson.
  • spring-boot-starter-actuator — monitoring endpoints.

These starter combinations can add 50-100 MB to the image. Not including the transitive dependencies pulled along.

Unlayered Fat JARs #

The default Spring Boot build produces a “fat JAR” containing all dependencies in one file. The problem: changing one line of code produces a new fat JAR, and Docker rebuilds all layers — no cache granularity.

The solution: Spring Boot 2.3+ has the layered JAR feature, which separates the fat JAR into layers (dependencies, spring-boot-loader, snapshot-dependencies, application). This lets the Docker cache work optimally.

Logging Libraries #

Spring Boot defaults to Logback with an encoder that sometimes adds large dependencies. JSON logging (for production) usually uses logstash-logback-encoder, adding ~5 MB.

Native Libraries #

Some Java libraries have native components:

  • Netty native (netty-tcnative).
  • Native database drivers.
  • Image processing libraries (ImageIO with native codecs).

Native libraries add size and complications in multi-stage builds.

3. The Main Principle: JRE Only, Distroless, Layered #

An ideal Java runtime image contains only: a JRE + the Spring Boot fat JAR + runtime configuration.

Full stop. No JDK, no Maven/Gradle, no source code, no test JARs, no build cache.

Three pillars to achieve it:

  1. Multi-stage builds — separate build (needs JDK + Maven) from runtime (needs JRE only).
  2. Distroless JRE — a very slim runtime image, without a shell or package manager.
  3. Layered JARs — split the fat JAR into layers for cache optimization.

4. Multi-Stage Strategy with Distroless #

4.1 Project Setup #

To maximize layered JARs, add configuration to pom.xml or build.gradle:

Maven (pom.xml):

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <layers>
                    <enabled>true</enabled>
                </layers>
            </configuration>
        </plugin>
    </plugins>
</build>

Gradle (build.gradle):

bootJar {
    layered {
        enabled = true
    }
}

After the build, Spring Boot produces an app.jar that can be extracted into several layers.

4.2 A Production-Grade Dockerfile #

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Extract the Layered JAR ====
FROM eclipse-temurin:21-jdk-jammy AS builder

WORKDIR /build

# Copy and extract the layered JAR
COPY --chmod=0755 mvnw /build/mvnw
COPY .mvn /build/.mvn
COPY pom.xml /build/pom.xml

# Cache the dependency download
RUN ./mvnw dependency:go-offline -B

COPY src /build/src
RUN ./mvnw package -DskipTests -B

# Extract the layered JAR
RUN mkdir -p /build/extracted
RUN java -Djarmode=layertools -jar /build/target/*.jar extract --destination /build/extracted

# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/java21-debian12:nonroot

WORKDIR /app

# Copy layers in the correct order (least-changing at the bottom)
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./

USER nonroot:nonroot

EXPOSE 8080

ENV JAVA_TOOL_OPTIONS="\
  -XX:+UseContainerSupport \
  -XX:MaxRAMPercentage=75.0 \
  -XX:+ExitOnOutOfMemoryError \
  -Djava.security.egd=file:/dev/./urandom"

ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Key explanations:

Stage 1: Build & Extract

  • eclipse-temurin:21-jdk-jammy — JDK 21 on Ubuntu Jammy. A large image, but it contains the tools needed for the build.
  • dependency:go-offline — downloads all dependencies up front. This maximizes caching: as long as pom.xml doesn’t change, this layer is reused.
  • java -Djarmode=layertools -jar ... extract — extracts the fat JAR into separate layers.

Stage 2: Runtime

  • gcr.io/distroless/java21-debian12:nonroot — a distroless JRE image + the nonroot user. No shell, no package manager. ~70 MB.
  • The layer copy order matters a lot:
    • dependencies (least-changing) copied first
    • spring-boot-loader (rarely changes)
    • snapshot-dependencies (rarely changes)
    • application (most-changing) copied last
  • Java tool options — container-aware JVM flags.

Typical size: 70-130 MB (depending on Spring Boot dependencies).

4.3 A Simpler Version (Without Layered JARs) #

If you don’t want the layered JAR hassle, a simpler version:

# ==== Stage 1: Build ====
FROM maven:3.9-eclipse-temurin-21 AS builder

WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn package -DskipTests -B

# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/java21-debian12:nonroot

WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar

USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

Size: 90-150 MB — slightly bigger than the layered version because one application layer contains everything.

5. JVM Tuning for Containers #

Java 10+ has container awareness, letting the JVM understand container memory limits. Without it, the JVM sees the host’s total memory, and default allocations can be too large or too small.

Container-Aware Flags #

# Enable container support (Java 10+)
-XX:+UseContainerSupport

# Set the heap as a percentage of the container memory limit
-XX:MaxRAMPercentage=75.0

# Initial heap also uses a percentage
-XX:InitialRAMPercentage=50.0

MaxRAMPercentage=75.0 means the JVM heap will be at most 75% of the container memory limit. The remaining 25% goes to off-heap (metaspace, thread stacks, native memory).

Choose the Right JVM Runtime #

JVMImage SizePerformanceNotes
HotSpotStandardStandardDefault in OpenJDK
OpenJ9SmallerFaster startupEclipse Adoptium alternative
GraalVM NativeVery smallVery fast startupBut needs native compilation

For most cases, HotSpot is enough. OpenJ9 is interesting when startup time matters. GraalVM native images are attractive for serverless, but they change how Spring Boot apps are written (AOT compilation, reflection limits).

Choose the Right JRE Base Image #

# Eclipse Temurin (Adoptium) — most common
FROM eclipse-temurin:21-jre-jammy

# Distroless — the slimmest
FROM gcr.io/distroless/java21-debian12:nonroot

# Amazon Corretto — optimized for AWS
FROM amazoncorretto:21-alpine

# Alibaba Dragonwell — optimized for Chinese CPU architectures
FROM dragonwell8:21-alpine

For production, eclipse-temurin or distroless are the safest choices. amazoncorretto is good if deploying on AWS.

6. Dependency Audits #

Spring Boot pulls in many transitive dependencies. Regular audits are important to keep images slim.

Identify Large Dependencies #

# After the build, check each dependency's size
mvn dependency:tree | sort -k 4 -n -r | head -20

Or with Gradle:

./gradlew dependencies --configuration runtimeClasspath

Audit Strategies #

Remove unused starters. Don’t import spring-boot-starter-data-jpa if you don’t use a database. Spring Boot is modular — use only what you need.

Audit transitive dependencies. Sometimes one starter pulls many transitive deps you don’t directly use. Check with mvn dependency:tree.

Choose slimmer alternatives. Some Java libraries are famously wasteful:

  • Jackson — JSON serialization, but can be replaced with Gson or Moshi (smaller).
  • Hibernate — full ORM, but jOOQ or Jdbi are slimmer for query-heavy apps.
  • Logback — default, but log4j2 with AsyncLogger can be more efficient.

Exclude Dependencies #

For dependencies that aren’t used but get pulled transitively:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>

7. Production Logging #

Spring Boot logs to STDOUT by default via Logback. For production, this configuration needs strengthening.

logback-spring.xml:

<configuration>
    <appender name="STDOUT_JSON" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeMdcKeyName>traceId</includeMdcKeyName>
            <includeMdcKeyName>spanId</includeMdcKeyName>
            <customFields>{"service":"myapp","env":"production"}</customFields>
        </encoder>
    </appender>
    
    <root level="INFO">
        <appender-ref ref="STDOUT_JSON"/>
    </root>
</configuration>

Important principles:

  • JSON structured logs for production (not plain text).
  • Include trace IDs for distributed tracing.
  • Log level via env var (LOG_LEVEL).
  • No file logging — let the orchestrator collect from STDOUT.

8. Healthchecks #

Spring Boot Actuator provides the /actuator/health endpoint, ideal for container healthchecks.

Enable in application.yml:

management:
  endpoint:
    health:
      show-details: when_authorized
      probes:
        enabled: true
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
  endpoints:
    web:
      exposure:
        include: health,info,metrics

In the Dockerfile (for images with a shell, not distroless):

HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/actuator/health || exit 1

For distroless (no wget/curl):

  • Move the healthcheck to the orchestrator (Kubernetes liveness/readiness probes).
  • Or use Spring Boot Actuator’s exposed endpoint and let the orchestrator probe it.

Spring Boot distinguishes two probes:

  • Liveness (/actuator/health/liveness) — does the container need a restart?
  • Readiness (/actuator/health/readiness) — is the container ready for traffic?

Use both in Kubernetes so rolling updates and scaling work correctly.

9. Signal Handling #

The JVM handles SIGTERM correctly by default — when the signal arrives, the JVM shuts down gracefully. But there are things to ensure:

  • Exec form in ENTRYPOINT (not shell form), so signals reach the JVM directly.
  • Graceful shutdown timeout set in Spring Boot properties:
server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

This gives 30 seconds for in-flight requests to finish before the JVM exits.

If using docker stop with the default 10-second grace period, make sure requests can finish within that time. If not, add docker stop -t 60 ... or set it in Compose/Kubernetes.

10. Security Hardening #

10.1 Non-Root Users #

Mandatory:

# For distroless, the nonroot user already exists
FROM gcr.io/distroless/java21-debian12:nonroot
USER nonroot:nonroot

# For regular base images, create an explicit user
RUN groupadd -r spring && useradd -r -g spring -d /home/spring spring
USER spring

10.2 JVM Security Flags #

Some flags recommended for production:

# Disable insecure features
-Djava.security.properties=/path/to/java.security

# Enable TLS only (disable SSL)
-Djdk.tls.disabledAlgorithms=SSLv3,RC4,MD5withRSA

# Restrict reflective access (Java 9+)
--add-opens=java.base/java.lang=ALL-UNNAMED

10.3 Container Security #

Read-only root filesystem (if the application supports it):

docker run --read-only --tmpfs /tmp myapp

Spring Boot works with a read-only root as long as nothing writes to the filesystem (everything goes through temp dirs or logs to STDOUT).

Drop capabilities:

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp

AppArmor or SELinux profiles per your organization’s security standards.

10.4 Vulnerability Scanning #

Integrate scanning into CI:

- name: Build
  run: docker build -t myapp:${{ github.sha }} .
- name: Scan
  run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}

Java images are famous for many transitive dependencies, and CVEs appear regularly. Rebuild images regularly to pull patched base images.

11. Anti-Patterns to Avoid #

✗ Using a JDK in the Runtime Stage #

// ✗ 300+ MB image because it carries a JDK
FROM openjdk:21
COPY app.jar /app.jar
CMD ["java", "-jar", "/app.jar"]

Solution: Use a JRE or distroless in the runtime stage.

✗ Not Using Multi-Stage #

// ✗ Build tools end up in the final image
FROM maven:3.9
WORKDIR /build
COPY . .
RUN mvn package
CMD ["java", "-jar", "target/app.jar"]

Solution: Multi-stage, with the runtime stage having no Maven.

✗ Copying Source Code to the Runtime Image #

// ✗ Java source code sits in the runtime image
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY src ./src
COPY target/app.jar .
CMD ["java", "-jar", "app.jar"]

Solution: Only copy app.jar from the build stage.

✗ Not Using Layered JARs #

// ✗ Every code change = rebuilding all layers
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build
COPY . .
RUN mvn package

FROM eclipse-temurin:21-jre
COPY --from=builder /build/target/app.jar app.jar

Solution: Extract the layered JAR and copy per layer.

✗ Hardcoded Memory Settings #

// ✗ Xmx isn't adaptive to container limits
ENTRYPOINT ["java", "-Xmx512m", "-jar", "app.jar"]

Solution: Use MaxRAMPercentage so the JVM is adaptive.

✗ Tagging Images Without a Strategy #

# ✗ Can't roll back
docker build -t myapp:latest .

# ✓ Can roll back
docker build -t myapp:1.4.0 .
docker build -t myapp:1.4.0-$BUILD_NUMBER .

12. A Production-Grade Spring Boot Dockerfile Example #

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Build with Layered JAR ====
FROM eclipse-temurin:21-jdk-jammy AS builder

WORKDIR /build

# Cache Maven dependencies
COPY pom.xml ./
COPY .mvn .mvn
COPY mvnw ./
RUN chmod +x mvnw && ./mvnw dependency:go-offline -B

# Build the application
COPY src ./src
RUN ./mvnw package -DskipTests -B

# Extract the layered JAR
RUN java -Djarmode=layertools \
    -jar /build/target/*.jar extract \
    --destination /build/extracted

# ==== Stage 2: Distroless Runtime ====
FROM gcr.io/distroless/java21-debian12:nonroot

WORKDIR /app

# Copy layers in cache-maximizing order
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./

# JVM tuning
ENV JAVA_TOOL_OPTIONS="\
  -XX:+UseContainerSupport \
  -XX:MaxRAMPercentage=75.0 \
  -XX:+ExitOnOutOfMemoryError \
  -Djava.security.egd=file:/dev/./urandom"

# Actuator port
EXPOSE 8080

# Run as non-root
USER nonroot:nonroot

# Spring Boot launcher
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

This image’s characteristics:

  • Final size: 70-130 MB (depending on dependencies).
  • JRE only in the runtime stage.
  • Layered cache: code changes only rebuild the application layer.
  • Container-aware JVM.
  • Non-root user.
  • No shell, no package manager.

13. When to Use Which Strategy #

ConditionChoiceReason
Standard Spring Boot APIDistroless + layered JARSmall size, secure, optimal cache
Microservice with many depsDistroless + auditDependency audits mandatory
Serverless / FargateDistrolessCold-start time matters
Legacy apps (JDK 8/11)Temurin JRECan’t move to JDK 21 yet
Need native performanceGraalVM Native ImageVery fast startup, small memory
High-security enterpriseDistroless + custom baseMinimal attack surface

14. Distroless vs Alpine: Don’t Be Mistaken #

Many engineers assume alpine is always smaller. For Java, this is often not true.

AspectAlpineDistroless
libcmuslglibc
Java compatibilitySometimes problematicVery stable
DebuggingEasy (has a shell)Hard
Java image sizeSometimes largerConsistently small
Official supportCommunityGoogle + community

Why is Alpine sometimes larger for Java?

  • musl libc sometimes pulls inefficient compatibility libraries.
  • The JVM on Alpine is sometimes compiled with musl-specific patches that add size.
  • Native libraries (Netty, Bouncy Castle, etc.) can be bigger on musl.

Distroless with glibc is usually smaller and more stable for modern Java. Use Alpine only if you’ve already tested your application on Alpine.

15. Java Dockerfile Review Checklist #

BASE IMAGE:
  □ Explicit tag (eclipse-temurin:21.0.4-jre-jammy, not latest)
  □ Runtime stage JRE only, not JDK
  □ Distroless or slim JRE, not JDK
  □ Not openjdk:* (too large)

BUILD:
  □ Multi-stage build
  □ Layered JAR enabled in pom.xml/build.gradle
  □ Dependency caching (mvn dependency:go-offline)
  □ Source code copied separately from pom.xml
  □ Build tools (Maven/Gradle) don't enter the runtime image

LAYER:
  □ dependencies layer (least-changing)
  □ spring-boot-loader layer
  □ snapshot-dependencies layer
  □ application layer (most-changing)

RUNTIME:
  □ USER nonroot
  □ UseContainerSupport enabled
  □ MaxRAMPercentage set
  □ Healthcheck (Actuator)
  □ Logs to STDOUT (Logback JSON)
  □ ENTRYPOINT in exec form

SIZE:
  □ < 150 MB for distroless runtime
  □ < 250 MB for slim JRE runtime
  □ docker history shows no odd layers

SECURITY:
  □ No secrets in the image
  □ Strict .dockerignore
  □ Image scanned with trivy/grype
  □ Non-root user
  □ JVM security flags (TLS only, no SSL)
  □ Base image up to date

DEPENDENCY:
  □ Regular dependency audits
  □ Remove unused starters
  □ Exclude unused transitive deps
  □ Efficient logging libraries chosen

Summary #

  • Slim Java images are very possible — it’s not fate. What separates a 450 MB image from a 70 MB image is choosing JRE only, distroless, and layered JARs.
  • Size reality: 70-130 MB (distroless + layered), 110-150 MB (alpine JRE), 180-220 MB (default JRE), 300-450 MB (JDK — anti-pattern).
  • Multi-stage builds are mandatory. The build stage has JDK + Maven; the runtime stage only JRE + fat JAR.
  • Layered JARs must be enabled in Spring Boot 2.3+ so the Docker cache works optimally, per dependency vs per code.
  • Distroless is the production defaultgcr.io/distroless/java21-debian12:nonroot. Alpine isn’t always smaller for Java.
  • Container-aware JVM-XX:+UseContainerSupport and -XX:MaxRAMPercentage=75.0 so the heap adapts to the container memory limit.
  • JRE only, not JDKeclipse-temurin:21-jre in the runtime stage, eclipse-temurin:21-jdk in the build stage.
  • Audit dependencies — Spring Boot pulls in many transitive deps. Remove unused starters, exclude irrelevant libraries.
  • Log to STDOUT, not files — Logback with LogstashEncoder for JSON structured logs.
  • Healthchecks via Actuator/actuator/health/liveness and /actuator/health/readiness for orchestrator probes.
  • Slim images need solid observability — JSON logs, metrics, healthchecks, and graceful shutdown. Distroless enforces this discipline.
  • Explicit tags, not latesteclipse-temurin:21.0.4-jre-jammy. Rebuild regularly to pull patched base images.

← Previous: Python   Next: PHP →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact