Optimizing Java Docker Builds: A Guide to Multi-Stage Builds

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.

Leave a Reply

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


Macro Nepal Helper