Spring Boot Docker: Multi-Stage Builds, Layered JARs, and Buildpacks
There are three ways to containerise a Spring Boot application: a naive single-stage Dockerfile, a proper multi-stage Dockerfile with layered JARs, and Cloud Native Buildpacks. Each has different tradeoffs in build speed, image size, and maintenance overhead.
This guide covers all three approaches, explains why layered JARs matter for CI/CD speed, and shows how to produce small, secure, production-ready images.
The Problem with the Naive Dockerfile
Most tutorials show this:
FROM eclipse-temurin:21-jdk
COPY target/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
This works but has two serious problems:
- Includes JDK (compiler, tools) when you only need JRE at runtime — image is 400–500 MB instead of 150–200 MB
- Single layer for the entire JAR — every code change rebuilds a 50–100 MB layer even if only 1 KB of your code changed
Approach 1: Multi-Stage Dockerfile with Layered JARs
How Spring Boot layered JARs work
Spring Boot 2.3+ packages the JAR in layers optimized for Docker caching:
app.jar/
├── dependencies/ # third-party dependencies (rarely changes)
├── spring-boot-loader/ # Spring Boot JAR launcher (rarely changes)
├── snapshot-dependencies/ # SNAPSHOT dependencies (changes sometimes)
└── application/ # your code (changes every build)
By extracting and copying these as separate Docker layers, only the application/ layer (typically a few KB) is invalidated on each code change. The dependencies layer (often 80–150 MB) is cached.
Extract layers
# Spring Boot 3.x uses jarmode=tools
java -Djarmode=tools -jar target/app.jar extract --destination target/extracted
# Spring Boot 2.x
java -Djarmode=layertools -jar target/app.jar extract --destination target/extracted
Full multi-stage Dockerfile
# Stage 1: Build
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
# Cache Maven dependencies separately (re-downloaded only when pom.xml changes)
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -q
# Build the application
COPY src/ src/
RUN ./mvnw -q package -DskipTests
# Extract layers
RUN java -Djarmode=tools -jar target/*.jar extract --destination target/extracted
# Stage 2: Runtime
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Security: non-root user
RUN addgroup -g 1001 appgroup \
&& adduser -u 1001 -G appgroup -s /bin/sh -D appuser
USER appuser
# Copy layers in order: most stable first (best cache hit rate)
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/dependencies/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=appuser:appgroup /build/target/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75", \
"-XX:+ExitOnOutOfMemoryError", \
"-Djava.security.egd=file:/dev/./urandom", \
"org.springframework.boot.loader.launch.JarLauncher"]
Build times with layered JARs
| Scenario | Without layers | With layers |
|---|---|---|
| First build | 3 min | 3 min |
| Code change only | 3 min | 15 sec |
| New dependency | 3 min | 45 sec |
| No changes | 3 min | 5 sec (all cached) |
The savings compound in CI where developers push multiple times per hour.
Approach 2: Cloud Native Buildpacks
Buildpacks build a container image from source code without a Dockerfile. Spring Boot Maven and Gradle plugins support this out of the box:
# Build image — no Dockerfile needed
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:1.0
# Gradle
./gradlew bootBuildImage --imageName=myapp:1.0
Buildpacks automatically:
- Select the right JRE version
- Apply layering
- Add security hardening (non-root user, read-only filesystem)
- Configure memory limits and JVM flags
- Add a bill of materials (SBOM) to the image
Customize the buildpack
<!-- pom.xml -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>myapp:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
<BPE_JAVA_TOOL_OPTIONS>-XX:MaxRAMPercentage=75</BPE_JAVA_TOOL_OPTIONS>
<BP_NATIVE_IMAGE>false</BP_NATIVE_IMAGE>
</env>
</image>
</configuration>
</plugin>
Buildpacks vs Dockerfile
| Factor | Buildpacks | Multi-stage Dockerfile |
|---|---|---|
| Maintenance | Low — Paketo team maintains base images | You own the Dockerfile |
| Control | Less (via env vars and config) | Full control |
| SBOM / provenance | Automatic | Manual with --sbom flag |
| Native image support | Yes (BP_NATIVE_IMAGE=true) | Yes (custom Dockerfile) |
| Build speed | Slower (downloads builder image) | Faster after first run |
| Best for | Teams who want zero Dockerfile maintenance | Teams needing fine-grained control |
Approach 3: Distroless or Scratch Images
For the smallest possible image, use Google’s Distroless JRE image (no shell, no package manager):
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY . .
RUN ./mvnw -q package -DskipTests
RUN java -Djarmode=tools -jar target/*.jar extract --destination target/extracted
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /app
COPY --from=builder /build/target/extracted/dependencies/ ./
COPY --from=builder /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/target/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "org.springframework.boot.loader.launch.JarLauncher"]
Image size comparison:
| Base image | Size |
|---|---|
eclipse-temurin:21-jdk | ~500 MB |
eclipse-temurin:21-jre-alpine | ~180 MB |
distroless/java21 | ~120 MB |
| GraalVM native binary | ~80 MB |
Distroless images have no shell. You can’t exec into them interactively, which is a security feature (no attacker can get a shell) but means no kubectl exec pod -- /bin/sh for debugging. Use an ephemeral debug container for that.
Docker Compose for Local Development
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
image: order-service:dev
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: dev
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/orders
SPRING_DATASOURCE_USERNAME: orders
SPRING_DATASOURCE_PASSWORD: orders
depends_on:
postgres:
condition: service_healthy
develop:
watch:
- action: rebuild
path: src/
ignore:
- "**/*Test.java"
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: orders
POSTGRES_PASSWORD: orders
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U orders"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
The develop.watch block (Docker Compose watch mode, available since Compose 2.22) rebuilds and restarts the container automatically when source files change — similar to Spring Boot DevTools but at the container level.
GitHub Actions CI Pipeline
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Run tests
run: ./mvnw test
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # GitHub Actions cache for Docker layers
cache-to: type=gha,mode=max
The cache-from: type=gha line caches Docker layers in GitHub Actions cache — the dependencies/ layer persists between CI runs.
Security Checklist
- Run as non-root user (
USER appuserin Dockerfile) - Use JRE not JDK in runtime image
- Pin base image tags (not
latest) — e.g.,eclipse-temurin:21.0.3_9-jre-alpine - Set
EXPOSEto the actual port (documentation + firewall hint) - No secrets in
ENVinstructions — use K8s Secrets or Docker secrets - Set
HEALTHCHECKor rely on Kubernetes probes - Scan image with
docker scoutor Trivy in CI
# Scan for vulnerabilities
trivy image myapp:1.0
# Docker Scout
docker scout cves myapp:1.0
Quick Reference
# Minimal production Dockerfile (layered JARs)
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY . .
RUN ./mvnw -q package -DskipTests && \
java -Djarmode=tools -jar target/*.jar extract --destination target/extracted
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -g 1001 g && adduser -u 1001 -G g -s /bin/sh -D u && chown -R u:g /app
USER u
COPY --from=builder --chown=u:g /build/target/extracted/dependencies/ ./
COPY --from=builder --chown=u:g /build/target/extracted/spring-boot-loader/ ./
COPY --from=builder --chown=u:g /build/target/extracted/snapshot-dependencies/ ./
COPY --from=builder --chown=u:g /build/target/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","-XX:+ExitOnOutOfMemoryError","org.springframework.boot.loader.launch.JarLauncher"]
# Buildpacks (no Dockerfile)
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:1.0
Summary
Use a multi-stage Dockerfile with Spring Boot layered JARs for fast, cached CI builds — only the application layer rebuilds on code changes. Use Cloud Native Buildpacks when you want zero Dockerfile maintenance. Use Distroless for the smallest, most secure runtime images. Always run as a non-root user, use JRE not JDK, pin base image tags, and never put secrets in ENV instructions.
