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