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 Type | Image Size | Startup Time | Security | Use Case |
|---|---|---|---|---|
| Standard | ~300MB | ~3-5s | Medium | General purpose |
| Optimized | ~150MB | ~2-3s | High | Production |
| JLink Minimal | ~80MB | ~1-2s | High | Resource-constrained |
| Native Image | ~50MB | ~0.1s | High | Performance-critical |
| Distroless | ~60MB | ~2s | Very High | Security-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.xmlfirst for dependency caching - Separate dependency download from source compilation
- Use
.dockerignoreto 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.