
Table of Contents
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.
jlink containing only required modules, and deploy on minimal bases like Eclipse Temurin Alpine or Google Distroless. This approach typically reduces final image size by 75–90% while maintaining full application compatibility and security compliance.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:
- COPY
pom.xmland runmvn dependency:go-offline - COPY source directories (
src/) - RUN
mvn package - 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.
How does jlink create custom runtimes to shrink Java Docker 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.
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 Image | Size (JRE) | Security | Compatibility | Best For |
|---|---|---|---|---|
| Eclipse Temurin Alpine | ~75 MB | High (musl, frequent patches) | Good (watch for musl/glibc issues) | Most Spring Boot apps |
| Google Distroless (Java) | ~90 MB | Highest (no shell, no pkg manager) | Excellent (glibc-based) | Security-critical, compliant envs |
| Amazon Corretto Alpine | ~80 MB | High (AWS-maintained) | Good | AWS-native workloads |
| Ubuntu/Debian Slim | ~130 MB | Moderate (larger attack surface) | Best (full glibc, debugging tools) | Troubleshooting, legacy libs |
| Oracle Linux Slim | ~110 MB | High (Oracle support) | Excellent | Oracle 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, thenUSER 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.0to 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>ordiveto 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.
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.