Shrink Java Docker Images

Khimananda Oli 8 min read Programming and Languages
Shrink Java Docker Images

By Khimananda Oli | Last reviewed: August 2026

Bloated containers are a silent tax on your infrastructure budget and deployment velocity. When teams fail to shrink Java Docker images, they accumulate gigabytes of unnecessary build artifacts, slowing down CI pipelines and increasing cold-start latency in Kubernetes clusters. This guide provides the exact multi-stage patterns, jlink configurations, and base image strategies I use to cut production Java image sizes from 800MB+ to under 100MB without sacrificing runtime stability or observability.

Build StageJDK + Maven/GradleCompile & Testjdeps Analysisjlink Custom JRERuntime StageAlpine / DistrolessCustom JRE (~45MB)App JAR OnlyNon-root UserResult< 100 MBvs 800MB+ NaiveCOPYDEPLOY
Three-layer architecture to shrink Java Docker images: build isolation, custom runtime generation, and minimal deployment target.

How do you use multi-stage builds to shrink Java Docker images?

Multi-stage builds are the non-negotiable foundation when you need to shrink Java Docker images. The core principle is simple: separate the heavy build environment (JDK, Maven, Gradle, source code) from the lean runtime environment (JRE, application artifact). Without this separation, your production image carries 400MB+ of build tools that serve zero purpose at runtime and expand your attack surface unnecessarily.

Standard multi-stage pattern for Spring Boot

This Dockerfile demonstrates the canonical pattern that works reliably across Spring Boot, Quarkus, and Micronaut applications in 2026:

# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY . .
RUN ./gradlew bootJar --no-daemon

# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
USER nonroot:nonroot
ENTRYPOINT ["java", "-jar", "app.jar"]

The critical detail most engineers miss is the --no-daemon flag in Gradle. Without it, the Gradle daemon persists in the layer cache, adding 100–200MB of invisible bloat. For Maven users, add -B for batch mode and consider mvn dependency:go-offline before the compile step to maximize layer caching efficiency.

Layer ordering matters for cache hits

Structure your COPY commands to leverage Docker's layer caching. Copy dependency manifests (pom.xml or build.gradle) first, run dependency resolution, then copy source code. This means rebuilding after a code change doesn't re-download 200MB of dependencies:

  1. COPY pom.xml and run mvn dependency:go-offline
  2. COPY source directories (src/)
  3. RUN mvn package
  4. COPY only the resulting JAR to the runtime stage

This ordering can reduce CI build times by 60–80% on subsequent runs, which compounds significantly when you're running hundreds of builds per week across a team. For teams managing multiple microservices, pairing this with a private registry as discussed in the container registry guide prevents redundant pulls of base images.

The jlink tool (available since Java 9) is the single most impactful technique to shrink Java Docker images beyond basic multi-stage builds. Instead of shipping a full 200MB JRE, jlink analyzes your application's actual module dependencies and generates a custom runtime containing only what's needed—typically 40–60MB.

Analyzing dependencies with jdeps

Before running jlink, you must identify which platform modules your application requires. The jdeps tool automates this analysis:

jdeps \
  --print-module-deps \
  --ignore-missing-deps \
  --multi-release 21 \
  --class-path 'build/libs/*' \
  app.jar

This outputs a comma-separated list like java.base,java.logging,java.sql,java.naming,jdk.crypto.ec. The --ignore-missing-deps flag is essential for Spring Boot fat JARs where embedded dependencies trigger false positives. Always verify the output manually against your application's known functionality; missing a module causes cryptic NoClassDefFoundError failures at runtime.

Generating the custom JRE

Integrate jlink into your build stage to produce a tailored runtime:

RUN jlink \
    --add-modules java.base,java.logging,java.sql,java.naming,jdk.crypto.ec \
    --strip-debug \
    --no-man-pages \
    --no-header-files \
    --compress zip-9 \
    --output /custom-jre

The --compress zip-9 flag (Java 21+) replaces the deprecated --compress=2 syntax and achieves better compression ratios. The --strip-debug option removes symbol tables that are useless in production but account for 15–20MB. Copy /custom-jre to your runtime stage and reference it explicitly:

COPY --from=builder /custom-jre /opt/java
ENV JAVA_HOME=/opt/java
ENV PATH="$JAVA_HOME/bin:$PATH"

In practice, I've seen this reduce a Spring Boot API gateway from 320MB (full JRE) to 85MB (custom JRE + Alpine). The trade-off is build complexity: every dependency change requires re-running jdeps. Automate this in your CI pipeline rather than relying on manual updates. If you're deploying to Kubernetes, ensure your resource limits and requests reflect the smaller memory footprint to avoid over-provisioning.

App JARFat JAR InputjdepsModule Analysis--print-module-depsjlinkCustom Runtime--strip-debugCustom JRE~45 MB OutputOnly Required ModulesFull JDK~280 MBSavings~85% Reduction
jlink pipeline flow: analyze fat JAR modules, generate stripped custom JRE, achieve 85% size reduction to shrink Java Docker images.

Which base image should you choose when optimizing Java containers?

Base image selection determines your security posture, compatibility ceiling, and ultimate size floor. The ecosystem has matured significantly, and the wrong choice can negate all other optimizations or introduce subtle production failures.

Base ImageSize (JRE)SecurityCompatibilityBest For
Eclipse Temurin Alpine~75 MBHigh (musl, frequent patches)Good (watch for musl/glibc issues)Most Spring Boot apps
Google Distroless (Java)~90 MBHighest (no shell, no pkg manager)Excellent (glibc-based)Security-critical, compliant envs
Amazon Corretto Alpine~80 MBHigh (AWS-maintained)GoodAWS-native workloads
Ubuntu/Debian Slim~130 MBModerate (larger attack surface)Best (full glibc, debugging tools)Troubleshooting, legacy libs
Oracle Linux Slim~110 MBHigh (Oracle support)ExcellentOracle Java shops

Distroless deserves special attention for teams pursuing SOC 2 or ISO 27001 compliance. With no shell, no package manager, and no extraneous binaries, the attack surface is minimal. However, debugging becomes harder—you can't exec into the container. Use debug variants during development and switch to standard variants for production. If you need to inspect running containers, integrate OpenTelemetry as covered in instrumenting apps with OpenTelemetry so observability doesn't depend on shell access.

Alpine's musl libc caveat: Some Java libraries (notably those using JNI with native glibc dependencies) fail silently or crash on musl. Test thoroughly before committing. If you hit incompatibilities, Amazon Corretto's Debian-slim variant offers a middle ground between size and compatibility.

What common mistakes prevent teams from reducing Java container size?

Even experienced teams leave significant optimization on the table due to overlooked details. These are the anti-patterns I encounter most frequently in audits and code reviews.

  • Running as root: Never run Java containers as root. It violates least-privilege principles and fails most security scanners. Create a dedicated user in your Dockerfile: RUN addgroup -S appgroup && adduser -S appuser -G appgroup, then USER appuser.
  • Ignoring .dockerignore: Without a proper .dockerignore, your build context includes .git/, node_modules/, IDE configs, and local logs. This inflates build time and can accidentally embed secrets. Explicitly allowlist rather than blocklist.
  • Hardcoding JVM flags: Container-aware JVM defaults have improved, but you should still set -XX:MaxRAMPercentage=75.0 to respect cgroup memory limits. Without this, the JVM may allocate heap based on host memory, causing OOMKills in Kubernetes.
  • Skipping vulnerability scanning: A small image isn't secure by default. Integrate Trivy or Grype into your CI pipeline. Smaller images actually scan faster, making this less painful. See container image scanning with Trivy for implementation details.
  • Not measuring layer sizes: Use docker history <image> or dive to inspect layer contributions. You'll often find a single misordered COPY command adding 200MB of rebuild churn.

Another subtle issue: copying entire build directories instead of specific artifacts. Always use explicit paths like COPY --from=builder /app/target/app.jar /app/app.jar rather than wildcard copies that drag in test reports, source maps, and temporary files.

850 MBNaiveJDK + Source320 MBMulti-StageJRE Only85 MBjlink + AlpineCustom JRE65 MBDistrolessNo ShellImage Size Comparison by Optimization Level-62%-73%-24%
Progressive size reduction when you shrink Java Docker images: naive (850MB) → multi-stage (320MB) → jlink Alpine (85MB) → Distroless (65MB).

Implementing optimized Java containers in production

Optimizing Java container images is an iterative engineering discipline, not a one-time configuration task. Start with multi-stage builds as your baseline—they deliver immediate wins with minimal risk. Add jlink when image size directly impacts your cost model or deployment SLAs. Move to Distroless when compliance requirements demand minimal attack surfaces and you've invested in external observability.

Measure everything. Track image size as a CI metric alongside test coverage. Set alerts for regressions. A 50MB creep over three months indicates process decay. In my experience helping teams across Nepal and globally, the organizations that treat container size as a first-class quality attribute—not an afterthought—are the ones that scale sustainably and pass audits without last-minute panic.

If your team needs hands-on guidance implementing these patterns, auditing existing container strategies, or preparing infrastructure for compliance reviews, reach out to discuss your specific architecture. Optimized containers are just one layer of production-ready systems; getting the full stack right requires methodical, security-first engineering.

Frequently Asked Questions

Eclipse Temurin Alpine or Amazon Corretto Alpine typically yield the smallest footprints, often under 200MB with JRE only. These use musl libc instead of glibc, reducing layer size significantly while maintaining compatibility with most Spring Boot and Quarkus applications in production environments.

Multi-stage builds commonly reduce final image size by 60-80% by excluding JDK, build tools, and source code. Only the compiled JAR and minimal JRE copy to the runtime stage, eliminating Maven caches and intermediate artifacts that bloat single-stage images unnecessarily.

No, jlink requires modular applications and cannot process traditional fat JARs directly. You must either modularize your application or use jdeps to identify required modules, then build a custom runtime image separately before copying your application JAR into the final container layer.

JRE images contain only runtime components needed to execute compiled Java bytecode, typically 50-100MB smaller than full JDK images. Production containers should always use JRE bases since compilation tools, debuggers, and development libraries add unnecessary attack surface and storage overhead.

Yes, GraalVM native compilation produces standalone binaries requiring no JVM, resulting in images under 100MB with sub-second startup. However, native builds have longer compile times, limited reflection support, and require extensive testing for library compatibility compared to standard JRE optimization techniques.

Use jdeps on your compiled JAR to analyze module dependencies, then compare against your pom.xml or build.gradle. Tools like depcheck or gradle-dependency-analyzer also flag declared but unused libraries that increase both build artifact size and final container layer footprint unnecessarily.

Enable gzip compression in your Dockerfile COPY commands and configure Maven or Gradle to produce compressed JARs. Zstandard compression in BuildKit offers better ratios than gzip for Java archives, reducing transfer times and storage costs without impacting runtime decompression performance significantly.

Large images despite slim bases usually result from copying entire build directories, including test resources, documentation, or uncompressed dependencies. Audit each COPY instruction, exclude non-essential files via .dockerignore, and verify you are not accidentally including node_modules or frontend build artifacts.

No, layer caching only impacts build speed, not final image size. However, ordering instructions from least to most frequently changed optimizes cache hit rates during rebuilds, reducing CI pipeline duration without altering the actual byte count of your deployed container artifact.

Yes, Google distroless Java images remove shells, package managers, and system utilities, minimizing attack surface. They contain only the JRE and CA certificates, preventing shell access if containers are compromised while maintaining full Java runtime compatibility for standard enterprise applications.

Layered JARs separate dependencies, classes, and resources into distinct archive sections that map to individual Docker layers. This enables better caching since dependency layers change less frequently than application code, reducing registry bandwidth and improving pull performance across distributed deployment environments.

Use dive to inspect layer contents and identify large files, trivy for vulnerability scanning with size reporting, and docker history to trace layer creation commands. These tools reveal hidden bloat sources like temporary files, logs, or duplicate libraries embedded within your container filesystem.

Yes, removing package manager caches, Maven repositories, and temporary build files in the same RUN instruction as installation prevents them from persisting in layers. Always combine cleanup commands with installation steps using && operators to ensure deletions occur within the same filesystem snapshot.

No, UPX compresses native executables, not Java bytecode archives. For JAR compression, use pack200 alternatives or enable ZIP_STORED mode in your build tool. Focus optimization efforts on base image selection and dependency pruning rather than binary compression for Java container workloads.

Java 21 LTS provides optimal balance with improved module system, enhanced jlink capabilities, and virtual threads reducing memory overhead. Newer versions include better container awareness and CDS improvements that decrease both image footprint and startup time compared to older Java 17 or 11 releases.