Article
In the world of containerized Java applications, Docker image size, security, and build efficiency are critical concerns. Multi-stage builds are a powerful Docker feature that allows you to optimize your build process by separating build-time dependencies from runtime requirements. This article explores how to leverage multi-stage builds to create minimal, secure, and efficient Java application containers.
Understanding Multi-Stage Builds
Multi-stage builds enable you to use multiple FROM statements in your Dockerfile, where each stage can have different base images and dependencies. The key benefits for Java applications include:
- Smaller Image Sizes: Only include runtime dependencies in the final image
- Improved Security: Eliminate build tools and source code from production images
- Better Caching: Optimize layer caching for faster builds
- Simplified Build Process: Single Dockerfile for entire build pipeline
Basic Multi-Stage Build Pattern
1. Simple Two-Stage Build
# Stage 1: Build stage FROM maven:3.9.5-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . COPY src ./src RUN mvn clean package -DskipTests # Stage 2: Runtime stage FROM eclipse-temurin:17-jre-jammy WORKDIR /app COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]
Advanced Multi-Stage Optimization Techniques
1. Layered JAR Optimization
Modern Spring Boot applications can leverage layered JARs for even better optimization:
# Stage 1: Build stage with layered JAR extraction FROM maven:3.9.5-eclipse-temurin-21 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests && \ java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted # Stage 2: Runtime stage with optimized layers FROM eclipse-temurin:21-jre-jammy WORKDIR /app # Copy dependencies layer (most stable) COPY --from=builder /app/target/extracted/dependencies/ ./ # Copy spring-boot-loader layer COPY --from=builder /app/target/extracted/spring-boot-loader/ ./ # Copy snapshot dependencies layer COPY --from=builder /app/target/extracted/snapshot-dependencies/ ./ # Copy application layer (changes most frequently) COPY --from=builder /app/target/extracted/application/ ./ EXPOSE 8080 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
2. Multi-Module Maven Project Optimization
For complex multi-module projects:
# Stage 1: Dependency resolution FROM maven:3.9.5-eclipse-temurin-21 AS deps WORKDIR /app COPY pom.xml . COPY core/pom.xml core/pom.xml COPY api/pom.xml api/pom.xml COPY service/pom.xml service/pom.xml RUN mvn dependency:go-offline -B # Stage 2: Build core module FROM deps AS core-builder COPY core/src ./core/src RUN mvn compile -pl core -am -DskipTests # Stage 3: Build service module FROM deps AS service-builder COPY --from=core-builder /app/core/target ./core/target COPY --from=core-builder /root/.m2 ./root/.m2 COPY service/src ./service/src RUN mvn compile -pl service -am -DskipTests # Stage 4: Build API module and package FROM deps AS final-builder COPY --from=service-builder /app/service/target ./service/target COPY --from=service-builder /root/.m2 ./root/.m2 COPY api/src ./api/src COPY core/src ./core/src COPY service/src ./service/src RUN mvn clean package -DskipTests # Stage 5: Runtime image FROM eclipse-temurin:21-jre-jammy AS runtime WORKDIR /app COPY --from=final-builder /app/api/target/*.jar app.jar # Security hardening RUN addgroup --system --gid 1000 javauser && \ adduser --system --uid 1000 --gid 1000 javauser && \ chown -R javauser:javauser /app USER javauser EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]
3. Gradle Multi-Stage Build
For Gradle-based projects:
# Stage 1: Gradle build stage FROM gradle:8.4.0-jdk21 AS builder WORKDIR /app # Copy gradle files first for better caching COPY build.gradle.kts . COPY settings.gradle.kts . COPY gradle.properties . COPY gradle ./gradle COPY src ./src # Build the application RUN gradle build --no-daemon -x test # Extract layers if using Spring Boot RUN java -Djarmode=layertools -jar build/libs/*.jar extract --destination build/extracted # Stage 2: Runtime image FROM eclipse-temurin:21-jre-jammy WORKDIR /app # Copy extracted layers COPY --from=builder /app/build/extracted/dependencies/ ./ COPY --from=builder /app/build/extracted/spring-boot-loader/ ./ COPY --from=builder /app/build/extracted/snapshot-dependencies/ ./ COPY --from=builder /app/build/extracted/application/ ./ # Security setup RUN groupadd -r spring && useradd -r -g spring spring USER spring EXPOSE 8080 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
4. JLink Custom Runtime Image
For ultra-minimal images using jlink:
# Stage 1: JDK for building and jlink
FROM eclipse-temurin:21-jdk-jammy AS jdk-builder
# Stage 2: Build application
FROM maven:3.9.5-eclipse-temurin-21 AS build
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
# Stage 3: Create custom JRE
FROM jdk-builder AS jlink-builder
RUN jlink \
--add-modules java.base,java.sql,java.naming,java.management,java.instrument,java.security.jgss \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /custom-jre
# Stage 4: Final minimal image
FROM debian:12-slim
WORKDIR /app
# Copy custom JRE
COPY --from=jlink-builder /custom-jre /opt/jre
ENV JAVA_HOME=/opt/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"
# Copy application
COPY --from=build /app/target/*.jar app.jar
# Create non-root user
RUN addgroup --system javagroup && adduser --system --ingroup javagroup javauser
USER javauser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Advanced Optimization Techniques
1. Build Argument Optimization
# Use build args for flexibility
ARG MAVEN_IMAGE=maven:3.9.5-eclipse-temurin-21
ARG RUNTIME_IMAGE=eclipse-temurin:21-jre-jammy
ARG APP_PORT=8080
# Build stage
FROM ${MAVEN_IMAGE} AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests
# Runtime stage
FROM ${RUNTIME_IMAGE}
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE ${APP_PORT}
ENTRYPOINT ["java", "-jar", "app.jar"]
2. Security Hardening
FROM maven:3.9.5-eclipse-temurin-21 AS builder WORKDIR /app COPY . . RUN mvn clean package -DskipTests FROM eclipse-temurin:21-jre-jammy AS runtime # Security best practices RUN groupadd -r spring && useradd -r -g spring spring && \ apt-get update && apt-get install -y --no-install-recommends \ tini && \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /app/target/*.jar app.jar RUN chown spring:spring app.jar USER spring # Use tini as init process for proper signal handling ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["java", "-jar", "app.jar"]
3. Multi-Architecture Builds
# Stage 1: Build for multiple architectures FROM --platform=$BUILDPLATFORM maven:3.9.5-eclipse-temurin-21 AS builder WORKDIR /app COPY . . RUN mvn clean package -DskipTests # Stage 2: Multi-arch runtime FROM eclipse-temurin:21-jre-jammy WORKDIR /app COPY --from=builder /app/target/*.jar app.jar # Install architecture-specific dependencies if needed RUN if [ "$TARGETARCH" = "arm64" ]; then \ apt-get update && apt-get install -y --no-install-recommends \ some-arm-specific-package; \ fi && \ rm -rf /var/lib/apt/lists/* EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]
Build Optimization Scripts
1. Maven Build Optimization
Create a build-optimized.sh script:
#!/bin/bash set -e echo "Building optimized Java application..." # Clean previous builds mvn clean # Download all dependencies first (improves caching) echo "Downloading dependencies..." mvn dependency:go-offline -B # Run tests echo "Running tests..." mvn test # Build the application echo "Building application..." mvn package -DskipTests # Extract layers if using Spring Boot if ls target/*.jar 1> /dev/null 2>&1; then echo "Extracting JAR layers..." java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted fi echo "Build completed successfully!"
2. Docker Build Script
Create a docker-build.sh script:
#!/bin/bash
set -e
APP_NAME="my-java-app"
VERSION="${1:-latest}"
PLATFORMS="${2:-linux/amd64,linux/arm64}"
echo "Building $APP_NAME:$VERSION for $PLATFORMS"
# Build using Docker Buildx for multi-architecture
docker buildx build \
--platform $PLATFORMS \
--tag $APP_NAME:$VERSION \
--tag $APP_NAME:latest \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from=type=registry,ref=$APP_NAME:latest \
--push . # Remove --push for local build
echo "Build completed: $APP_NAME:$VERSION"
Performance Comparison
Let's analyze the impact of multi-stage builds:
Before Optimization (Single Stage):
FROM maven:3.9.5-eclipse-temurin-21 WORKDIR /app COPY . . RUN mvn clean package -DskipTests EXPOSE 8080 CMD ["java", "-jar", "target/app.jar"]
Image Size: ~750MB
After Optimization (Multi-Stage):
# Build stage FROM maven:3.9.5-eclipse-temurin-21 AS builder WORKDIR /app COPY . . RUN mvn clean package -DskipTests # Runtime stage FROM eclipse-temurin:21-jre-jammy WORKDIR /app COPY --from=builder /app/target/app.jar . EXPOSE 8080 CMD ["java", "-jar", "app.jar"]
Image Size: ~200MB (73% reduction)
Best Practices
1. Layer Caching Optimization
FROM maven:3.9.5-eclipse-temurin-21 AS builder # Copy POM first (most stable layer) COPY pom.xml . # Download dependencies (cached unless POM changes) RUN mvn dependency:go-offline -B # Copy source code (changes frequently) COPY src ./src # Build application (rebuilt when source changes) RUN mvn clean package -DskipTests
2. Security Hardening
FROM eclipse-temurin:21-jre-jammy # Use minimal base image # Create non-root user RUN adduser --system --group appuser USER appuser # Remove unnecessary packages RUN apt-get update && apt-get remove -y .*doc.* && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Use health checks HEALTHCHECK --interval=30s --timeout=3s \ CMD curl -f http://localhost:8080/actuator/health || exit 1
3. Environment-Specific Builds
ARG PROFILE=prod
FROM maven:3.9.5-eclipse-temurin-21 AS builder
ARG PROFILE
COPY . .
RUN mvn clean package -DskipTests -P${PROFILE}
FROM eclipse-temurin:21-jre-jammy
COPY --from=builder /app/target/*.jar app.jar
ENV SPRING_PROFILES_ACTIVE=${PROFILE}
CMD ["java", "-jar", "app.jar"]
Complete Production Example
Dockerfile:
# syntax=docker/dockerfile:1.4 # Stage 1: Base with build tools FROM maven:3.9.5-eclipse-temurin-21 AS builder WORKDIR /workspace/app # Copy pom.xml for dependency caching COPY pom.xml . RUN mvn dependency:go-offline -B # Copy source code COPY src src COPY checkstyle.xml . # Build application RUN mvn clean package -DskipTests # Extract layers for Spring Boot RUN java -Djarmode=layertools -jar target/*.jar extract --destination target/extracted # Stage 2: Production runtime FROM eclipse-temurin:21-jre-jammy:nonroot AS runtime # Copy layers COPY --from=builder /workspace/app/target/extracted/dependencies/ ./ COPY --from=builder /workspace/app/target/extracted/spring-boot-loader/ ./ COPY --from=builder /workspace/app/target/extracted/snapshot-dependencies/ ./ COPY --from=builder /workspace/app/target/extracted/application/ ./ # Security USER nonroot # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 EXPOSE 8080 ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
docker-compose.yml for Build:
version: '3.8' services: app: build: context: . dockerfile: Dockerfile target: runtime image: my-java-app:latest ports: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=prod
Build and Deployment
Build Commands:
# Basic build docker build -t my-java-app . # Multi-architecture build docker buildx build --platform linux/amd64,linux/arm64 -t my-java-app . # Build with build args docker build --build-arg PROFILE=prod -t my-java-app:prod . # Build with cache docker build --cache-from my-java-app:latest -t my-java-app:latest .
Conclusion
Multi-stage builds are essential for creating optimized, secure, and efficient Java application containers. By implementing these patterns:
- Reduce image size by 70-80% by eliminating build dependencies
- Improve security by removing build tools and source code from runtime images
- Speed up builds through intelligent layer caching
- Support multiple architectures with the same Dockerfile
- Implement production-ready best practices for containerized Java applications
The combination of multi-stage builds, layered JARs, and security hardening creates robust, minimal containers that are ideal for modern cloud-native Java applications. These optimizations lead to faster deployments, reduced storage costs, and improved security posture in production environments.