Multi-Stage Builds Optimization in Java: Complete Guide

Introduction

Multi-stage Docker builds are a powerful technique for creating optimized, secure, and efficient Java application containers. By separating build dependencies from runtime environment, you can significantly reduce image size, improve security, and speed up deployment.


Architecture Overview

[Stage 1: Builder] → [Stage 2: Dependency Cache] → [Stage 3: Runtime] → [Final Image] ↓ ↓ ↓ ↓ JDK + Maven Dependency Layers JRE Only Minimal Image Source Code Compiled Classes Application Security Hardened Compilation Test Execution Optimized JVM Production Ready

Step 1: Basic Multi-Stage Build

Standard Multi-Stage Dockerfile

Dockerfile

# Stage 1: Build stage FROM eclipse-temurin:17-jdk-jammy as builder WORKDIR /app # Copy Maven wrapper and pom.xml COPY mvnw . COPY .mvn .mvn COPY pom.xml . # Download dependencies (cached unless pom.xml changes) RUN ./mvnw dependency:go-offline -B # Copy source code COPY src src # Build the application (including tests) RUN ./mvnw clean package -DskipTests # Stage 2: Runtime stage FROM eclipse-temurin:17-jre-jammy as runtime WORKDIR /app # Create non-root user for security RUN groupadd -r appuser && useradd -r -g appuser appuser # Copy JAR from builder stage COPY --from=builder /app/target/*.jar app.jar # Create necessary directories and set permissions RUN mkdir -p /app/logs && chown -R appuser:appuser /app # Switch to non-root user USER appuser # Expose port EXPOSE 8080 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 # Run the application ENTRYPOINT ["java", "-jar", "app.jar"]

Step 2: Advanced Multi-Stage Optimization

Optimized Multi-Stage with Layer Caching

Dockerfile.optimized

# Stage 1: Base build image with common dependencies FROM eclipse-temurin:17-jdk-jammy as base # Install necessary build tools RUN apt-get update && apt-get install -y \ curl \ git \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy Maven wrapper and pom.xml first (for better caching) COPY mvnw . COPY .mvn .mvn COPY pom.xml . # Download dependencies in separate layer (cached unless pom.xml changes) RUN ./mvnw dependency:go-offline -B # Stage 2: Test stage (optional - for CI/CD) FROM base as tester # Copy source code for testing COPY src src # Run tests RUN ./mvnw test # Stage 3: Package stage FROM base as packager # Copy source code COPY src src # Build the application without running tests RUN ./mvnw clean package -DskipTests # Extract layers for better Docker layer caching RUN java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted # Stage 4: Production runtime FROM eclipse-temurin:17-jre-jammy as production # Install security updates and minimal runtime dependencies RUN apt-get update && apt-get install -y \ curl \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Create non-root user RUN groupadd -r appuser && useradd --no-log-init -r -g appuser appuser WORKDIR /app # Copy extracted layers from packager stage COPY --from=packager /app/target/extracted/dependencies/ ./ COPY --from=packager /app/target/extracted/spring-boot-loader/ ./ COPY --from=packager /app/target/extracted/snapshot-dependencies/ ./ COPY --from=packager /app/target/extracted/application/ ./ # Create necessary directories and set permissions RUN mkdir -p /app/logs /app/tmp && \ chown -R appuser:appuser /app # Switch to non-root user USER appuser # Expose port EXPOSE 8080 # Configure JVM for containers ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom" # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 # Use Spring Boot layered JAR approach ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Step 3: JVM Optimization for Containers

JVM-Optimized Multi-Stage Build

Dockerfile.jvm-optimized

# Stage 1: Build with performance optimizations FROM eclipse-temurin:17-jdk-jammy as builder WORKDIR /app # Copy build files COPY mvnw . COPY .mvn .mvn COPY pom.xml . # Download dependencies RUN ./mvnw dependency:go-offline -B # Copy source COPY src src # Build with optimizations RUN ./mvnw clean package -DskipTests \ -Dmaven.compiler.source=17 \ -Dmaven.compiler.target=17 \ -Dmaven.compiler.release=17 # Stage 2: JLink custom runtime (minimal JRE) FROM eclipse-temurin:17-jdk-jammy as jlink WORKDIR /app # Copy JAR from builder COPY --from=builder /app/target/*.jar app.jar # Create custom JRE with only required modules RUN jdeps --ignore-missing-deps \ --multi-release 17 \ --print-module-deps \ --class-path 'app.jar' \ app.jar > jre-modules.txt # Create minimal JRE RUN jlink --verbose \ --add-modules $(cat jre-modules.txt),jdk.unsupported \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress=2 \ --output /custom-jre # Stage 3: Final production image FROM debian:bookworm-slim # Install minimal security updates RUN apt-get update && \ apt-get upgrade -y && \ apt-get install -y --no-install-recommends \ ca-certificates \ curl \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Create app user RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app # Copy custom JRE from jlink stage COPY --from=jlink /custom-jre /opt/java # Copy application from builder stage COPY --from=builder /app/target/*.jar app.jar # Set Java home ENV JAVA_HOME=/opt/java ENV PATH="${JAVA_HOME}/bin:${PATH}" # Create necessary directories RUN mkdir -p /app/logs /app/tmp && \ chown -R appuser:appuser /app USER appuser EXPOSE 8080 # Optimized JVM options for containers ENV JAVA_OPTS="-XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -XX:ParallelGCThreads=2 \ -XX:ConcGCThreads=2 \ -Djava.security.egd=file:/dev/./urandom \ -Dspring.jmx.enabled=false" # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Step 4: Spring Boot Specific Optimizations

Spring Boot Layered JAR Optimization

Dockerfile.spring-layered

# Stage 1: Build with Spring Boot layers FROM eclipse-temurin:17-jdk-jammy as builder WORKDIR /app # Copy build configuration COPY mvnw . COPY .mvn .mvn COPY pom.xml . # Resolve dependencies RUN ./mvnw dependency:go-offline -B # Copy source code COPY src src # Build with layered JAR support RUN ./mvnw clean package -DskipTests \ -Dspring-boot.repackage.layeredArtifact=true # Extract layers for optimized Docker caching RUN java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted # Stage 2: Production runtime FROM eclipse-temurin:17-jre-jammy # Install security updates RUN apt-get update && \ apt-get upgrade -y && \ apt-get install -y --no-install-recommends \ curl \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Create app user RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app # Copy layers in optimized order (least changing first) COPY --from=builder /app/target/extracted/dependencies/ ./ COPY --from=builder /app/target/extracted/spring-boot-loader/ ./ COPY --from=builder /app/target/extracted/snapshot-dependencies/ ./ COPY --from=builder /app/target/extracted/application/ ./ # Create directories and set permissions RUN mkdir -p /app/logs /app/tmp && \ chown -R appuser:appuser /app USER appuser EXPOSE 8080 # Spring Boot specific optimizations ENV SPRING_OUTPUT_ANSI_ENABLED=NEVER ENV JAVA_OPTS="-XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -Xss256k \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -Djava.awt.headless=true \ -Djava.security.egd=file:/dev/./urandom \ -Dspring.backgroundpreinitializer.ignore=true" HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Maven Configuration for Layered JARs

pom.xml with Spring Boot Maven Plugin

<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <layers> <enabled>true</enabled> </layers> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> <!-- Maven Compiler Plugin --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <release>17</release> <parameters>true</parameters> <optimize>true</optimize> </configuration> </plugin> <!-- Maven Resources Plugin with filtering --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-resources-plugin</artifactId> <version>3.3.1</version> <configuration> <delimiters> <delimiter>@</delimiter> </delimiters> <useDefaultDelimiters>false</useDefaultDelimiters> </configuration> </plugin> </plugins> </build>

Step 5: GraalVM Native Image Build

Native Image Multi-Stage Build

Dockerfile.native

# Stage 1: Build native image FROM ghcr.io/graalvm/native-image:17-ol9 as native-builder WORKDIR /app # Install build dependencies RUN microdnf update -y && \ microdnf install -y \ maven \ git \ && microdnf clean all # Copy source code COPY . . # Build native image RUN mvn clean package -DskipTests -Pnative # Stage 2: Minimal runtime for native executable FROM redhat/ubi9-micro # Install CA certificates and basic security RUN microdnf update -y && \ microdnf install -y \ ca-certificates \ curl \ && microdnf clean all # Create non-root user RUN groupadd -r appuser && useradd -r -g appuser appuser WORKDIR /app # Copy native executable from builder COPY --from=native-builder /app/target/*-runner /app/application # Create necessary directories RUN mkdir -p /app/logs /app/tmp && \ chown -R appuser:appuser /app USER appuser EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["/app/application"]

Maven Profile for Native Image

pom.xml with Native Profile

<profiles> <profile> <id>native</id> <properties> <packaging>native</packaging> </properties> <dependencies> <dependency> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <version>0.9.28</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <version>0.9.28</version> <executions> <execution> <id>build-native</id> <goals> <goal>compile-no-fork</goal> </goals> <phase>package</phase> </execution> </executions> <configuration> <imageName>${project.artifactId}</imageName> <mainClass>${start-class}</mainClass> <buildArgs> <buildArg>--enable-url-protocols=http,https</buildArg> <buildArg>--initialize-at-build-time=org.slf4j.MDC</buildArg> <buildArg>-H:+ReportExceptionStackTraces</buildArg> </buildArgs> </configuration> </plugin> </plugins> </build> </profile> </profiles>

Step 6: Security-Hardened Multi-Stage Build

Security-Focused Multi-Stage Build

Dockerfile.secure

# Stage 1: Build with security scanning FROM eclipse-temurin:17-jdk-jammy as builder # Install security scanner RUN apt-get update && apt-get install -y \ curl \ && curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin WORKDIR /app # Copy build files COPY mvnw . COPY .mvn .mvn COPY pom.xml . # Download dependencies and scan for vulnerabilities RUN ./mvnw dependency:go-offline -B && \ trivy filesystem --exit-code 1 --severity HIGH,CRITICAL /root/.m2/repository # Copy source code COPY src src # Build application RUN ./mvnw clean package -DskipTests # Stage 2: Security-hardened runtime FROM gcr.io/distroless/java17:nonroot WORKDIR /app # Copy JAR from builder stage COPY --from=builder /app/target/*.jar app.jar # Use the nonroot user provided by distroless (uid: 65532) USER nonroot EXPOSE 8080 # Security-focused JVM options ENV JAVA_OPTS="-XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -Djava.security.egd=file:/dev/./urandom \ -Djava.awt.headless=true \ -Dfile.encoding=UTF-8" ENTRYPOINT ["java", "-jar", "app.jar"]

Alternative: Alpine-based Secure Build

Dockerfile.alpine-secure

# Stage 1: Build stage FROM eclipse-temurin:17-jdk-alpine as builder WORKDIR /app # Install necessary tools RUN apk add --no-cache \ curl \ maven # Copy build files COPY pom.xml . COPY mvnw . COPY .mvn .mvn # Download dependencies RUN ./mvnw dependency:go-offline -B # Copy source code COPY src src # Build application RUN ./mvnw clean package -DskipTests # Stage 2: Production stage with Alpine JRE FROM eclipse-temurin:17-jre-alpine # Security hardening RUN apk add --no-cache \ curl \ && addgroup -S appuser && adduser -S appuser -G appuser \ && rm -rf /var/cache/apk/* WORKDIR /app # Copy JAR from builder COPY --from=builder /app/target/*.jar app.jar # Create necessary directories RUN mkdir -p /app/logs /app/tmp && \ chown -R appuser:appuser /app USER appuser EXPOSE 8080 # Alpine-specific JVM optimizations ENV JAVA_OPTS="-XX:+UseContainerSupport \ -XX:MaxRAMPercentage=75.0 \ -Xss256k \ -Djava.security.egd=file:/dev/./urandom" HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Step 7: Development vs Production Builds

Development Multi-Stage Build

Dockerfile.dev

# Stage 1: Development build with hot reload FROM eclipse-temurin:17-jdk-jammy as development WORKDIR /app # Install additional development tools RUN apt-get update && apt-get install -y \ curl \ vim \ git \ && rm -rf /var/lib/apt/lists/* # Copy Maven wrapper and pom.xml COPY mvnw . COPY .mvn .mvn COPY pom.xml . # Download dependencies (cached layer) RUN ./mvnw dependency:go-offline -B # Copy source code COPY src src # Expose debug port EXPOSE 8080 5005 # Development-specific JVM options ENV JAVA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005" # Run with hot reload for development CMD ["./mvnw", "spring-boot:run", "-Dspring-boot.run.jvmArguments=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"] # Stage 2: Production build from same source FROM development as production-builder # Build production JAR RUN ./mvnw clean package -DskipTests # Stage 3: Production runtime FROM eclipse-temurin:17-jre-jammy as production WORKDIR /app # Create non-root user RUN groupadd -r appuser && useradd -r -g appuser appuser # Copy JAR from builder COPY --from=production-builder /app/target/*.jar app.jar # Set up directories and permissions RUN mkdir -p /app/logs && chown -R appuser:appuser /app USER appuser EXPOSE 8080 # Production JVM options ENV JAVA_OPTS="-XX:+UseContainerSupport -Djava.security.egd=file:/dev/./urandom" ENTRYPOINT ["java", "-jar", "app.jar"]

Step 8: Build Arguments and Customization

Configurable Multi-Stage Build

Dockerfile.configurable

# Build arguments for customization ARG JAVA_VERSION=17 ARG MAVEN_VERSION=3.9.5 ARG APP_PORT=8080 # Stage 1: Configurable builder FROM eclipse-temurin:${JAVA_VERSION}-jdk-jammy as builder ARG MAVEN_VERSION ARG BUILD_NUMBER=1 # Install specific Maven version RUN apt-get update && apt-get install -y \ curl \ && curl -fsSL https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ | tar xzf - -C /opt \ && ln -s /opt/apache-maven-${MAVEN_VERSION} /opt/maven ENV PATH="/opt/maven/bin:${PATH}" WORKDIR /app # Copy build files COPY pom.xml . COPY mvnw . COPY .mvn .mvn # Download dependencies RUN mvn dependency:go-offline -B # Copy source code COPY src src # Build with build number RUN mvn clean package -DskipTests -Dbuild.number=${BUILD_NUMBER} # Stage 2: Configurable runtime FROM eclipse-temurin:${JAVA_VERSION}-jre-jammy ARG APP_PORT ARG APP_USER=appuser # Create user RUN groupadd -r ${APP_USER} && useradd -r -g ${APP_USER} ${APP_USER} WORKDIR /app # Copy application COPY --from=builder /app/target/*.jar app.jar # Set up directories RUN mkdir -p /app/logs && chown -R ${APP_USER}:${APP_USER} /app USER ${APP_USER} EXPOSE ${APP_PORT} ENV JAVA_OPTS="-XX:+UseContainerSupport -Djava.security.egd=file:/dev/./urandom" HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:${APP_PORT}/actuator/health || exit 1 ENTRYPOINT ["java", "-jar", "app.jar"]

Build Script with Arguments

build.sh

#!/bin/bash # Build configuration JAVA_VERSION=${1:-17} MAVEN_VERSION=${2:-3.9.5} APP_PORT=${3:-8080} BUILD_NUMBER=${4:-$(date +%s)} echo "Building with:" echo " Java: ${JAVA_VERSION}" echo " Maven: ${MAVEN_VERSION}" echo " Port: ${APP_PORT}" echo " Build: ${BUILD_NUMBER}" docker build \ --build-arg JAVA_VERSION=${JAVA_VERSION} \ --build-arg MAVEN_VERSION=${MAVEN_VERSION} \ --build-arg APP_PORT=${APP_PORT} \ --build-arg BUILD_NUMBER=${BUILD_NUMBER} \ -t myapp:${BUILD_NUMBER} \ -f Dockerfile.configurable . # Tag for different environments docker tag myapp:${BUILD_NUMBER} myapp:latest docker tag myapp:${BUILD_NUMBER} myapp:java${JAVA_VERSION}

Step 9: Docker Compose for Multi-Stage Testing

Development and Testing Setup

docker-compose.yml

version: '3.8' services: # Development build with hot reload app-dev: build: context: . dockerfile: Dockerfile.dev target: development ports: - "8080:8080" - "5005:5005" volumes: - .:/app - maven-repo:/root/.m2 environment: - SPRING_PROFILES_ACTIVE=dev command: ["./mvnw", "spring-boot:run"] # Production build test app-prod: build: context: . dockerfile: Dockerfile.optimized target: production ports: - "8081:8080" environment: - SPRING_PROFILES_ACTIVE=prod depends_on: - postgres # Database for testing postgres: image: postgres:15-alpine environment: POSTGRES_DB: myapp POSTGRES_USER: appuser POSTGRES_PASSWORD: secret ports: - "5432:5432" volumes: maven-repo:

Multi-Architecture Build Script

build-multi-arch.sh

#!/bin/bash # Multi-architecture build script VERSION=${1:-latest} PLATFORMS="linux/amd64,linux/arm64" echo "Building multi-architecture image for version: $VERSION" # Create builder instance docker buildx create --name multiarch-builder --use # Build for multiple platforms docker buildx build \ --platform ${PLATFORMS} \ -t mycompany/myapp:${VERSION} \ -t mycompany/myapp:latest \ --push \ . # Inspect the built image docker buildx imagetools inspect mycompany/myapp:${VERSION}

Performance Comparison

Build Size Comparison

Build TypeImage SizeStartup TimeSecurityUse Case
Standard~300MB~3-5sMediumGeneral purpose
Optimized~150MB~2-3sHighProduction
JLink Minimal~80MB~1-2sHighResource-constrained
Native Image~50MB~0.1sHighPerformance-critical
Distroless~60MB~2sVery HighSecurity-focused

Build Time Optimization

# ❌ Bad - No layer caching COPY . . RUN mvn clean package # ✅ Good - Optimal layer caching COPY pom.xml . RUN mvn dependency:go-offline COPY src src RUN mvn clean package

Best Practices

1. Layer Caching Optimization

  • Copy pom.xml first for dependency caching
  • Separate dependency download from source compilation
  • Use .dockerignore to exclude unnecessary files

2. Security Hardening

  • Use minimal base images (distroless, Alpine)
  • Run as non-root user
  • Regular security updates
  • Vulnerability scanning in CI/CD

3. JVM Optimization

  • Use container-aware JVM options
  • Set appropriate memory limits
  • Choose optimal garbage collector
  • Enable container support

4. Build Performance

  • Use buildkit features
  • Parallelize independent operations
  • Cache dependencies appropriately
  • Use multi-stage builds effectively

5. Monitoring & Debugging

  • Include health checks
  • Proper logging configuration
  • JMX/actuator endpoints
  • Debug capabilities in development

Conclusion

Multi-stage Docker builds provide significant benefits for Java applications:

  • Reduced Image Size: From ~300MB to ~50MB with optimizations
  • Improved Security: Minimal attack surface with distroless images
  • Faster Deployment: Smaller images deploy quicker
  • Better Caching: Optimized layer caching for faster builds
  • Production Readiness: Security-hardened, optimized runtime

By implementing these multi-stage build strategies, you can create Java applications that are secure, efficient, and optimized for modern containerized environments.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper