Docker Best Practices for Java Applications: Slim Images & Multi-Stage Builds (2026)

Docker best practices for Java Spring Boot applications

A freshly generated Spring Boot Docker image using the naive FROM openjdk:17 + COPY jar + CMD approach weighs over 700MB. A production-optimized image for the same application using multi-stage builds and a distroless base image weighs under 120MB — an 83% reduction that translates directly to faster CI/CD pipelines, lower registry storage costs, reduced attack surface, and faster pod startup times in Kubernetes. This guide shows you exactly how to get there.

Why Java Docker Images Are Bloated (And How to Fix)

The default path to Dockerizing a Java application is deceptively simple: pull the full JDK image, copy the fat JAR, and run it. This approach packs three categories of unnecessary weight. First, the full JDK includes the Java compiler, javac, header files, source archives, and development tools that are completely unused at runtime. A full JDK 21 image is ~620MB; a JRE is ~200MB; a custom minimal JRE via jlink can be under 60MB. Second, single-stage builds leave Maven/Gradle build caches, downloaded dependencies, and intermediate artifacts inside the image. Your 50MB application jar ends up in an image containing 400MB of build tooling. Third, not having a .dockerignore file causes the Docker daemon to send your entire project directory — including target/, .git/, local IDE files, and test data — to the build context, massively slowing builds even before the first RUN instruction executes.

The impact is not cosmetic. A 700MB image takes 3–5 minutes to pull on a cold Kubernetes node versus under 30 seconds for a 100MB image. Every CI/CD pipeline run that builds and pushes the image consumes proportionally more time. Security scanners find more vulnerabilities in bloated images simply because there is more surface area — unused OS packages, compiler tools, and debug utilities all carry CVEs that have no business being in a production runtime image. Fixing image bloat is one of the highest-ROI DevOps improvements for Java teams.

Multi-Stage Build: The Foundation

Multi-stage builds allow you to use one Docker stage for building the application (with full JDK and build tools) and a separate, smaller stage for the runtime image (with only the JRE). The build artifacts are copied from the build stage to the runtime stage — nothing else carries over. Here is a complete, production-grade multi-stage Dockerfile for a Spring Boot application using Maven:

# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build

# Copy dependency manifests first — enables Docker layer caching
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -q

# Copy source and build
COPY src/ src/
RUN ./mvnw package -DskipTests -q

# Extract layered jar for optimized layer caching
RUN java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted

# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app

# Create non-root user
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser

# Copy layered content in order of change frequency (least → most)
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/dependencies/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/application/ ./

USER appuser
EXPOSE 8080

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

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

The layered JAR extraction (jarmode=layertools) is a Spring Boot 2.3+ feature that splits the JAR into layers ordered by change frequency: dependencies (rarely changed), spring-boot-loader (changed on Spring Boot upgrades), snapshot-dependencies (changed on dependency updates), and application (changed on every code change). Each layer becomes a separate Docker layer. When you rebuild after only changing application code, Docker reuses the dependencies and spring-boot-loader layers from cache — only the application layer is rebuilt and pushed, typically reducing push time from minutes to seconds.

Image size comparison for a typical Spring Boot application:

# Before optimization (single-stage, full JDK)
FROM openjdk:17                  →  720MB total image

# After: multi-stage, JRE Alpine
FROM eclipse-temurin:21-jre-alpine  →  185MB total image

# After: distroless
FROM gcr.io/distroless/java21    →  120MB total image

Distroless & Minimal Base Images

Distroless images (from Google's gcr.io/distroless project) contain only the application runtime and its direct dependencies — no shell, no package manager, no system utilities. This means no /bin/sh, no apt, no curl. The security benefit is significant: an attacker who achieves code execution in your container has no shell to pivot with, no package manager to install additional tools, and no common utilities to chain exploits. Distroless images also fail Docker security scans at far lower CVE counts since the OS layer is stripped to the absolute minimum:

# Distroless Dockerfile (final stage only — build stage remains the same)
FROM gcr.io/distroless/java21-debian12 AS runtime
WORKDIR /app

COPY --from=builder /build/target/extracted/dependencies/ ./
COPY --from=builder /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/target/extracted/application/ ./

EXPOSE 8080
# Distroless uses USER 65532 (nonroot) by default
USER 65532:65532
ENTRYPOINT ["java", \
  "-XX:+UseContainerSupport", \
  "-XX:MaxRAMPercentage=75.0", \
  "-XX:+UseZGC", \
  "-Djava.security.egd=file:/dev/./urandom", \
  "org.springframework.boot.loader.launch.JarLauncher"]

Note that distroless images have no shell, so ENTRYPOINT ["sh", "-c", ...] will not work — use the exec form with direct Java invocation. This also means docker exec -it container /bin/sh will fail, which is intentional for production security. For debugging distroless containers, use ephemeral debug containers: kubectl debug -it <pod> --image=eclipse-temurin:21-jre-alpine --target=app.

Base image size comparison:

eclipse-temurin:21-jdk             ~620MB   # Full JDK — build only
eclipse-temurin:21-jre             ~260MB   # JRE — acceptable runtime
eclipse-temurin:21-jre-alpine      ~185MB   # Alpine JRE — good runtime
gcr.io/distroless/java21-debian12  ~120MB   # Distroless — best security
Custom jlink minimal JRE           ~80MB    # Maximum optimization

Layer Caching Optimization

Docker builds each instruction as a separate layer. If a layer's input (the instruction + all files it depends on) has not changed since the last build, Docker reuses the cached layer — skipping the instruction entirely. The critical principle is: order instructions from least frequently changed to most frequently changed. Dependencies change far less often than application code, so copy and resolve dependencies before copying source code:

# WRONG — copies all source first; any code change invalidates dependency cache
COPY . .
RUN ./mvnw package -DskipTests

# CORRECT — copy pom.xml and resolve deps first; source changes only invalidate last layer
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -q  # <-- Cached until pom.xml changes

COPY src/ src/
RUN ./mvnw package -DskipTests -q    # <-- Only invalidated when source changes

A properly maintained .dockerignore file is equally important for cache efficiency — it prevents unnecessary build context invalidation by excluding files that should never be sent to the Docker daemon:

# .dockerignore
.git/
.github/
.idea/
*.iml
target/
*.log
*.md
docker-compose*.yml
.env
.env.*
**/*.class
**/test-output/
coverage/
node_modules/

With a proper .dockerignore, incremental rebuilds after a code change typically complete in 8–12 seconds instead of 45–90 seconds, because Docker only invalidates and rebuilds the application layer rather than all layers from the first COPY.

JVM Flags for Container Environments

The JVM's default behavior is designed for bare-metal or VM deployments where it has access to all host resources. In a container with resource limits, the default behavior can cause OOMKilled pod terminations and misconfigured thread pools. Three critical JVM flags for container deployments:

# Dockerfile ENV or Kubernetes deployment JAVA_OPTS
ENV JAVA_OPTS="\
  -XX:+UseContainerSupport \
  -XX:MaxRAMPercentage=75.0 \
  -XX:InitialRAMPercentage=50.0 \
  -XX:MinRAMPercentage=25.0 \
  -XX:ActiveProcessorCount=2 \
  -XX:+UseZGC \
  -XX:+ZGenerational \
  -XX:MaxMetaspaceSize=256m \
  -XX:+OptimizeStringConcat \
  -Djava.security.egd=file:/dev/./urandom \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/tmp/heapdump.hprof"

-XX:+UseContainerSupport (default in JDK 8u191+) makes the JVM read memory and CPU limits from cgroup rather than the host. -XX:MaxRAMPercentage=75.0 allocates 75% of the container's memory limit to the heap — leaving 25% for Metaspace, thread stacks, code cache, and OS overhead. For a 1GB container, this allocates ~768MB heap. -XX:ActiveProcessorCount=2 explicitly sets the CPU count visible to the JVM — useful when your container is limited to 2 CPU cores but the host has 32, which would cause the JVM to size its ForkJoinPool and GC thread count for 32 cores, creating 30 unnecessary threads. Verify your flags are applied with java -XX:+PrintFlagsFinal -version 2>&1 | grep MaxHeapSize.

Docker Compose for Local Development

Local development with Docker Compose should mirror the production service topology — using the same databases, message brokers, and cache layers. This eliminates "works on my machine" issues from differences between H2 in-memory databases and production PostgreSQL. A complete development Compose file for a Spring Boot application:

version: "3.9"
services:
  app:
    build:
      context: .
      target: runtime          # Use only runtime stage for local dev
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/appdb
      SPRING_DATASOURCE_USERNAME: appuser
      SPRING_DATASOURCE_PASSWORD: apppass
      SPRING_REDIS_HOST: redis
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
      JAVA_OPTS: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=70.0"
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - /tmp/heapdumps:/tmp   # Mount heapdump path for debugging

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppass
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  postgres_data:

The healthcheck + depends_on: condition: service_healthy combination ensures the Spring Boot application only starts after PostgreSQL and Redis are accepting connections — preventing the common race condition where Spring Boot tries to connect before the database is ready and fails with a connection-refused error during startup.

Security Best Practices

Running containers as root is the most common Docker security mistake in Java deployments. If your application is compromised, a root-running container can write to any host path mounted as a volume, modify the container filesystem at will, and in certain configurations, escalate to host root. Always create and use a non-root user:

# In your Dockerfile
RUN addgroup --system --gid 1001 appgroup \
    && adduser --system --uid 1001 --ingroup appgroup --no-create-home appuser

# Set ownership on app directory
COPY --chown=appuser:appgroup --from=builder /build/target/extracted/ ./

USER 1001:1001  # Use numeric IDs — more portable across Linux distributions

Additional security hardening: use --read-only filesystem in your Kubernetes security context (mount writable directories explicitly for /tmp and logs), set allowPrivilegeEscalation: false and runAsNonRoot: true in the Kubernetes pod security context. Scan images in CI with Trivy before pushing to registry:

# In your GitHub Actions CI pipeline
- name: Scan image with Trivy
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}'
    format: 'sarif'
    output: 'trivy-results.sarif'
    severity: 'CRITICAL,HIGH'
    exit-code: '1'   # Fail build on critical/high CVEs

Never bake secrets (database passwords, API keys, JWT secrets) into Docker images using ENV or ARG instructions — they persist in image layers and are visible via docker history. Use Kubernetes Secrets, HashiCorp Vault, or AWS Secrets Manager to inject secrets at runtime via environment variables or mounted files.

Production Deployment with Kubernetes

A well-optimized Docker image is only the first step — the Kubernetes deployment configuration is equally critical for production reliability. Resource requests and limits, combined with correct liveness and readiness probes for Spring Boot Actuator, ensure stable scheduling and traffic routing:

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
      - name: app
        image: registry/app:1.0.0
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "1Gi"     # Set JVM MaxRAMPercentage=75 → ~768MB heap
            cpu: "1000m"
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 20   # Allow Spring context initialization
          periodSeconds: 10
          failureThreshold: 3
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 30   # Give more time than readiness
          periodSeconds: 30
          failureThreshold: 3
        securityContext:
          runAsNonRoot: true
          runAsUser: 1001
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true

Spring Boot 2.3+ exposes separate /actuator/health/liveness and /actuator/health/readiness endpoints. The liveness probe signals whether the application process is alive and should be restarted if it fails. The readiness probe signals whether the application is ready to serve traffic — it fails during startup and optionally during graceful shutdown, preventing traffic routing to a pod that is not ready.

FAQs: Java Docker Optimization

Q: Should I use Alpine-based images or Debian-based images for Java?
A: Alpine images use musl libc instead of glibc, which can cause subtle compatibility issues with JNI libraries, DNS resolution behavior, and some native dependencies. Eclipse Temurin's Alpine images are well-tested and safe for most Spring Boot applications. For maximum compatibility and easier debugging, use Debian slim (eclipse-temurin:21-jre) at the cost of ~60MB additional size. For production, Alpine is generally fine — just test thoroughly in staging.

Q: How do I handle database migration (Flyway) with Docker and Kubernetes?
A: Run migrations as a Kubernetes Job or Init Container before deploying the application pods. This ensures migrations complete before any application pod starts and prevents concurrent migration runs if multiple pods start simultaneously. Alternatively, use Flyway's built-in distributed lock (via flyway.lock-retry-count) to serialize migrations across pods, but the Job approach provides cleaner control.

Q: My Docker build cache is not being hit in CI — why?
A: Most CI systems start fresh containers without a local Docker cache. Use registry-based cache with --cache-from: pull the previous image layer from the registry before building, and Docker will reuse matching layers. GitHub Actions supports this with cache-from: type=registry in the Docker Buildx action. BuildKit's inline cache (--build-arg BUILDKIT_INLINE_CACHE=1) exports cache metadata into the image itself.

Q: How do I pass different configurations to the same Docker image in different environments?
A: Never bake environment-specific config into the image — this violates the 12-factor app principle of environment parity. Use Spring Boot's externalized configuration: pass SPRING_PROFILES_ACTIVE=prod and environment-specific variables via Kubernetes ConfigMaps and Secrets. The same image runs identically in dev, staging, and production — only the injected configuration differs.

Q: How do I debug a Spring Boot application running in a distroless container?
A: Use Kubernetes ephemeral debug containers: kubectl debug -it <pod-name> --image=eclipse-temurin:21-jre-alpine --target=app. This attaches a debug container sharing the pod's process namespace, allowing you to attach jstack, jmap, or a debugger to the running JVM process without modifying the production image or redeploying.

Key Takeaways

  • Multi-stage builds are mandatory: Build-stage tools (JDK, Maven, build cache) must never be in the production runtime image. A two-stage Dockerfile is the baseline for any production Java application.
  • Layer caching pays for itself: Copy pom.xml and resolve dependencies before copying source code. With proper layering, incremental CI builds drop from 3–5 minutes to under 30 seconds.
  • Container-aware JVM flags are not optional: -XX:UseContainerSupport + -XX:MaxRAMPercentage=75.0 prevents the JVM from over-allocating heap and causing OOMKilled pod restarts under load.
  • Distroless reduces attack surface: Removing the shell, package manager, and OS utilities from the runtime image eliminates the majority of CVEs found by container scanning tools.
  • Never run as root: Create a dedicated non-root user in the Dockerfile and enforce runAsNonRoot: true in Kubernetes security context. This is a baseline security requirement, not an optional hardening step.
  • Scan images in CI: Integrate Trivy or Docker Scout into your CI pipeline with a build failure on CRITICAL/HIGH CVEs. Catching vulnerabilities before deployment is far cheaper than patching production images under incident pressure.

Related Articles

Discussion / Comments

Join the conversation — your comment goes directly to my inbox.

← Back to Blog