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:

  1. Includes JDK (compiler, tools) when you only need JRE at runtime — image is 400–500 MB instead of 150–200 MB
  2. 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

ScenarioWithout layersWith layers
First build3 min3 min
Code change only3 min15 sec
New dependency3 min45 sec
No changes3 min5 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

FactorBuildpacksMulti-stage Dockerfile
MaintenanceLow — Paketo team maintains base imagesYou own the Dockerfile
ControlLess (via env vars and config)Full control
SBOM / provenanceAutomaticManual with --sbom flag
Native image supportYes (BP_NATIVE_IMAGE=true)Yes (custom Dockerfile)
Build speedSlower (downloads builder image)Faster after first run
Best forTeams who want zero Dockerfile maintenanceTeams 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 imageSize
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 appuser in 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 EXPOSE to the actual port (documentation + firewall hint)
  • No secrets in ENV instructions — use K8s Secrets or Docker secrets
  • Set HEALTHCHECK or rely on Kubernetes probes
  • Scan image with docker scout or 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.

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.