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:
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:
# --- 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
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
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
EXPOSEand/or hardcodinglocalhostdatabase 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), notlocalhost. - Running the container as root with no explicit
USERdirective in a production image — an unnecessary privilege escalation surface if the container is ever compromised.