Spring Boot with Docker

A real multi-stage Dockerfile using layered JARs for efficient Docker layer caching.

Why a naive Dockerfile is inefficient

The simplest possible Dockerfile for a Spring Boot app just copies the fat JAR and runs it:

Dockerfile
FROM eclipse-temurin:21-jre
COPY target/bookstore-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

This works, but it wastes Docker's layer caching. A Docker image is built in layers, and Docker only rebuilds (and re-uploads) the layers that actually changed since the last build. Here, the entire fat JAR is one single COPY, so changing a single line of application code invalidates that whole layer — meaning every dependency inside the JAR gets re-copied and re-pushed on every single code change, even though the dependencies themselves didn't change at all.

A multi-stage, layered Dockerfile

Spring Boot's layered JAR (see the Actuator/production deployment page in this track) exists specifically to fix this. A multi-stage build extracts those layers in a build stage, then copies each layer into the final image as its own Docker layer, ordered from least to most frequently changing:

Dockerfile
# --- Build stage ---
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN ./mvnw dependency:go-offline

COPY src src
RUN ./mvnw clean package -DskipTests

# Extract the fat jar into its constituent layers
RUN java -Djarmode=layertools -jar target/*.jar extract --destination extracted

# --- Final, runtime stage ---
FROM eclipse-temurin:21-jre
WORKDIR /app

# Each COPY becomes its own Docker layer, ordered least- to most-frequently-changing
COPY --from=build /workspace/extracted/dependencies/ ./
COPY --from=build /workspace/extracted/spring-boot-loader/ ./
COPY --from=build /workspace/extracted/snapshot-dependencies/ ./
COPY --from=build /workspace/extracted/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Why this ordering matters

Plaintext
Layer 1: dependencies           <- changes only when a library version bumps
Layer 2: spring-boot-loader     <- changes almost never
Layer 3: snapshot-dependencies  <- changes occasionally
Layer 4: application            <- changes on EVERY code commit

Docker caches each COPY layer independently and reuses a cached layer unchanged if its inputs haven't changed. With this ordering, an ordinary code change only invalidates the final application layer — the (much larger) dependencies layer is reused straight from cache, which means dramatically smaller image pushes/pulls in CI and faster deploys, since only the small, changed layer actually needs to be re-uploaded to the registry.

JarLauncher (from spring-boot-loader, extracted as its own layer) reconstructs the original JAR's classpath from the separately-copied layers at startup — the application runs identically to the single fat JAR, just assembled from layers instead of one big archive.

A minimal docker-compose.yml for local development

YAML
services:
  bookstore:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/bookstore
      SPRING_PROFILES_ACTIVE: docker
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: bookstore
      POSTGRES_PASSWORD: devpassword
    ports:
      - "5432:5432"

Common mistakes

  • COPY-ing the whole fat JAR as a single layer, or copying the entire source tree before restoring dependencies — either invalidates a large Docker layer on every single code change and defeats caching entirely.
  • Using a JDK image (eclipse-temurin:21-jdk) for the final runtime stage instead of a JRE image — the JDK includes an entire compiler toolchain the running application never needs, needlessly bloating the final image.
  • Forgetting EXPOSE and/or hardcoding localhost database URLs that don't resolve inside the container's own network — a containerized app needs to reach dependencies by their service/container name (db, above), not localhost.
  • Running the container as root with no explicit USER directive in a production image — an unnecessary privilege escalation surface if the container is ever compromised.