Contents
- Naive Dockerfile (What Not to Do)
- Multi-Stage Build
- Custom JRE with jlink
- Layer Caching for Fast Rebuilds
- Non-Root User & Security Hardening
- Spring Boot Buildpacks
- Distroless Base Images
- .dockerignore & Build Context
- Image Size Comparison
The Dockerfile below defines the container image build steps. Each instruction is annotated to explain its purpose.
# ❌ Single-stage — ships full JDK, build tools, source code
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
EXPOSE 8080
CMD ["java", "-jar", "target/app.jar"]
# Resulting image: ~700 MB
Problems: includes the full JDK, Maven, source code, and test dependencies — none of which are needed at runtime.
Stage 1 compiles and packages. Stage 2 is the lean runtime image — only the JAR and a JRE are copied across.
# Stage 1 — Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
# Copy only dependency specs first — exploits layer caching
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -q
# Now copy source and build
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# Stage 2 — Runtime
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
# Copy only the built JAR from the builder stage
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# Resulting image: ~220 MB
jlink creates a minimal JRE containing only the modules your application needs. Use jdeps to discover which modules the JAR requires.
# Stage 1 — Build the fat JAR
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml . && COPY .mvn/ .mvn/ && COPY mvnw .
RUN ./mvnw dependency:go-offline -q
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# Stage 2 — Create minimal JRE with jlink
FROM eclipse-temurin:21-jdk-alpine AS jre-builder
WORKDIR /jre-build
# Discover required modules from the fat JAR
COPY --from=builder /app/target/*.jar app.jar
RUN jdeps --ignore-missing-deps \
--print-module-deps \
--multi-release 21 \
--recursive \
--class-path 'BOOT-INF/lib/*' \
app.jar > modules.txt && \
cat modules.txt # e.g., java.base,java.logging,java.net.http,...
# Build custom JRE — add java.security.jgss,jdk.crypto.ec for HTTPS
RUN jlink \
--add-modules $(cat modules.txt),java.security.jgss,jdk.crypto.ec \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /custom-jre
# Stage 3 — Final minimal image
FROM alpine:3.19 AS runtime
WORKDIR /app
# Copy custom JRE and app
COPY --from=jre-builder /custom-jre /opt/jre
COPY --from=builder /app/target/*.jar app.jar
ENV JAVA_HOME=/opt/jre
ENV PATH="${JAVA_HOME}/bin:${PATH}"
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# Resulting image: ~85 MB
Spring Boot's layered JAR feature splits the fat JAR into dependency, snapshot-dependencies, spring-boot-loader, and application layers. Only changed layers are rebuilt and re-pulled.
org.springframework.boot
spring-boot-maven-plugin
true
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml . && COPY .mvn/ .mvn/ && COPY mvnw .
RUN ./mvnw dependency:go-offline -q
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# Extract layers from the layered JAR
FROM eclipse-temurin:21-jre-alpine AS layer-extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# Final runtime — copy layers in order of change frequency (most stable first)
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=layer-extractor /app/dependencies/ ./
COPY --from=layer-extractor /app/spring-boot-loader/ ./
COPY --from=layer-extractor /app/snapshot-dependencies/ ./
COPY --from=layer-extractor /app/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
With layered JARs, rebuilding after a code-only change copies only the application layer (a few KB) — not the 200+ MB dependency layer. This reduces CI build time from minutes to seconds for incremental changes.
The Dockerfile below defines the container image build steps. Each instruction is annotated to explain its purpose.
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=builder /app/target/*.jar app.jar
# Change ownership
RUN chown appuser:appgroup app.jar
# Drop to non-root
USER appuser
# Read-only filesystem — app writes to /tmp only
VOLUME ["/tmp"]
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+UseContainerSupport", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "app.jar"]
Cloud Native Buildpacks produce OCI-compliant images without writing a Dockerfile. Spring Boot's Maven and Gradle plugins include built-in support.
# Build image with Buildpacks (requires Docker running)
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=myorg/payment-service:latest
# Gradle
./gradlew bootBuildImage --imageName=myorg/payment-service:latest
org.springframework.boot
spring-boot-maven-plugin
myorg/${project.artifactId}:${project.version}
paketobuildpacks/builder-jammy-tiny
21
-XX:MaxRAMPercentage=75
Google's distroless images contain only the application and its runtime dependencies — no shell, no package manager. This dramatically reduces the attack surface.
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml . && COPY .mvn/ .mvn/ && COPY mvnw .
RUN ./mvnw dependency:go-offline -q
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# Distroless Java 21 — no shell, no apt, minimal OS
FROM gcr.io/distroless/java21-debian12:nonroot AS runtime
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
CMD ["app.jar"]
# ~130 MB, non-root by default, no shell attack surface
The output or file structure below illustrates the expected result.
# .dockerignore — exclude everything not needed for the build
target/
.git/
.github/
*.md
*.log
.idea/
*.iml
.DS_Store
node_modules/
**/*.class
**/*.jar
!target/*.jar # exception — include the built JAR if copying from host
A small build context means faster docker build — the daemon doesn't need to transfer gigabytes of compiled class files and IDE metadata.
| Approach | Base Image | Approx Size | Notes |
| Naive single-stage | eclipse-temurin:21-jdk | ~700 MB | Full JDK + build tools |
| Multi-stage | eclipse-temurin:21-jre-alpine | ~220 MB | JRE only, no source |
| Layered JAR | eclipse-temurin:21-jre-alpine | ~220 MB | Same size, faster incremental rebuild |
| Distroless | distroless/java21-debian12 | ~130 MB | No shell, minimal OS |
| jlink custom JRE | alpine:3.19 | ~85 MB | Smallest — only needed modules |
| Buildpacks (tiny) | paketobuildpacks/run-jammy-tiny | ~90 MB | No Dockerfile needed, reproducible |
| GraalVM native | distroless/static-debian12 | ~60 MB | AOT compiled, instant startup |