Dockerizing a Spring Boot Application
Spring Boot 12 min read
Why a fat-jar COPY wastes the layer cache, what buildpacks and jarmode do instead, the JVM flags a container needs, and the two settings that decide whether your deployment drains or drops requests.
Putting a Spring Boot application in a container is three lines. Getting an image that rebuilds
quickly, starts predictably and shuts down without dropping requests takes a little more, and the
differences are all in places a working docker build does not reveal.
Written against Spring Boot 3.2, Java 17 and Docker 25.
The three-line version, and its cost
FROM eclipse-temurin:17-jre-alpine
COPY target/notes-api-0.0.1-SNAPSHOT.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
This works and has two real problems.
The layer cache is wasted. A fat jar is one file of perhaps 50 MB, of which your code is a few hundred kilobytes and the rest is Spring, Hibernate and Jackson. Change one line of a controller and Docker invalidates the whole layer, so every build pushes 50 MB of unchanged dependencies to the registry.
It expects a prior mvn package. The image is only reproducible if whoever builds it ran the
right build first, which is exactly the assumption CI is supposed to remove.
Layered jars
Spring Boot’s jar knows how to split itself into layers ordered by how often they change:
$ java -Djarmode=tools -jar target/notes-api-0.0.1-SNAPSHOT.jar list-layers
dependencies
spring-boot-loader
snapshot-dependencies
application
# ---- build ----
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -B dependency:go-offline # cached until pom.xml changes
COPY src ./src
RUN mvn -B -DskipTests package
# ---- extract ----
FROM eclipse-temurin:17-jre AS extract
WORKDIR /app
COPY --from=build /src/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# ---- run ----
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=extract /app/extracted/dependencies/ ./
COPY --from=extract /app/extracted/spring-boot-loader/ ./
COPY --from=extract /app/extracted/snapshot-dependencies/ ./
COPY --from=extract /app/extracted/application/ ./
EXPOSE 8080
USER 1001:1001
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
The four COPY lines are four layers, ordered least-changing first. A code change now rebuilds and
pushes only the application layer, kilobytes rather than tens of megabytes.
COPY pom.xml before COPY src does the same for the Maven cache, which is the single biggest
build-time saving in the file.
Two version notes. -Djarmode=tools ... extract is the Spring Boot 3.3+ form; earlier versions use
-Djarmode=layertools ... extract. And the launcher class moved to
org.springframework.boot.loader.launch.JarLauncher in Boot 3.2: the old
org.springframework.boot.loader.JarLauncher fails with ClassNotFoundException, which is a
confusing error for a file that is plainly present.
Or skip the Dockerfile entirely
Spring Boot can build an image with no Dockerfile at all, using Cloud Native Buildpacks:
$ ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=notes-api:latest
The result is layered, runs as non-root, includes a memory calculator that sizes the heap from the container’s limit, and is rebuilt with security patches by updating the builder rather than your base image.
The trade is control: you configure it through builder settings rather than by editing a file, and debugging an unexpected result means learning how buildpacks work. For a standard web application it is the shortest correct path. For anything needing a specific base image or extra native packages, write the Dockerfile.
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>ghcr.io/example/notes-api:${project.version}</name>
<env>
<BP_JVM_VERSION>17</BP_JVM_VERSION>
</env>
</image>
</configuration>
</plugin>
What the JVM needs to know about its container
A JVM before Java 10 read the host’s memory and cores and ignored cgroup limits, which is where the
folklore about -Xmx in containers comes from. Current JVMs are container-aware:
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
MaxRAMPercentage is the flag to use rather than a fixed -Xmx. The container limit is a deployment
decision that changes; a hardcoded heap size does not follow it, and the two disagreeing is how you
get an OOM kill at 60% heap usage. Leave a quarter for thread stacks, metaspace, code cache and
direct buffers, the JVM’s total footprint is meaningfully larger than its heap.
ExitOnOutOfMemoryError matters in an orchestrator. Without it, a JVM that has exhausted its heap
often limps along serving errors while still passing a TCP health check. Exiting lets the platform
restart it.
JAVA_TOOL_OPTIONS rather than baking flags into ENTRYPOINT, so a deployment can override them
without a rebuild.
Configuration from the environment
Never bake configuration into the image. Relaxed binding means every Spring property has an environment-variable form:
$ docker run --rm -p 8080:8080 \
-e SPRING_DATASOURCE_URL='jdbc:mysql://db:3306/notes' \
-e SPRING_DATASOURCE_USERNAME=notes \
-e SPRING_DATASOURCE_PASSWORD="$DB_PASSWORD" \
-e SPRING_PROFILES_ACTIVE=prod \
notes-api:latest
spring.datasource.url becomes SPRING_DATASOURCE_URL. One image runs in every environment, which
is the property that makes a promotion pipeline possible.
Passing a secret with -e puts it in docker inspect and the shell history. For anything real, use
the orchestrator’s secret mechanism and mount it as a file, then point Spring at the file with
spring.config.import=optional:file:/run/secrets/.
Shutting down without dropping requests
Two settings, and both are off by default:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
With graceful, SIGTERM stops the connector accepting new connections and lets in-flight requests
finish. Without it, the container is killed mid-request on every single deployment: invisible at low
traffic, and a steady trickle of client errors at scale.
This only works if the signal reaches the JVM as PID 1, which the exec form of ENTRYPOINT
guarantees:
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"] # signals arrive
ENTRYPOINT java -jar /app.jar # shell form: sh is PID 1 and does not forward SIGTERM
The shell form is a genuinely common cause of “graceful shutdown does not work”, the JVM never sees the signal; the shell absorbs it and the runtime kills the container after the grace period.
Health checks
management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.health.probes.enabled=true
That last line publishes /actuator/health/liveness and /actuator/health/readiness as separate
endpoints, which is what a container platform wants, and the distinction is not cosmetic. Liveness
answers “restart this container”; readiness answers “send it traffic”. Pointing a liveness probe at a
check that includes the database means a database blip restarts every replica simultaneously. See
Actuator health groups for how to scope them.
A compose file for local work
services:
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: notes
MYSQL_ROOT_PASSWORD: secret
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-psecret"]
interval: 5s
retries: 10
volumes:
- db-data:/var/lib/mysql
app:
build: .
ports: ["8080:8080"]
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/notes
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: secret
depends_on:
db:
condition: service_healthy
volumes:
db-data:
condition: service_healthy rather than a plain depends_on. The short form waits for the container
to start, not for MySQL to accept connections, so the application starts, fails to connect, and
exits: the classic “works on the second docker compose up” symptom.
Frequently asked questions
Why is my image rebuilding 50 MB for a one-line change?
A fat jar is a single layer. Extract the
jar into its layers and COPY them separately, dependencies first.
Do I need a Dockerfile at all?
No. ./mvnw spring-boot:build-image produces a layered, non-root,
container-aware image via buildpacks. Write a Dockerfile when you need a specific base image or extra
OS packages.
Should I set -Xmx in a container?
Prefer -XX:MaxRAMPercentage=75. A fixed heap does not follow
the container limit, and the two disagreeing produces OOM kills well below the apparent heap ceiling.
Why is graceful shutdown not working?
Either server.shutdown=graceful is unset, or the
ENTRYPOINT uses the shell form so sh is PID 1 and never forwards SIGTERM. Use the exec form.
Why does ClassNotFoundException mention JarLauncher?
The launcher moved to
org.springframework.boot.loader.launch.JarLauncher in Spring Boot 3.2, the old package name no
longer exists.
layertools or tools?
-Djarmode=tools from Spring Boot 3.3 onward; -Djarmode=layertools
before that.
How do I pass configuration in?
Environment variables. Relaxed binding maps
spring.datasource.url to SPRING_DATASOURCE_URL, so one image serves every environment. Mount real
secrets as files rather than passing them with -e.
Why does my app fail on the first compose up and work on the second?
Plain depends_on waits for
the container to start, not for the database to accept connections. Add a healthcheck and
condition: service_healthy.
Should the container run as root?
No. Add a USER line, buildpacks do this for you. Do not rely
on the orchestrator’s security context alone; the image should be correct by itself.
Which base image?
A JRE image such as eclipse-temurin:17-jre for a jar. A full JDK is only
needed if something at runtime compiles. Alpine variants are smaller but use musl, which occasionally
matters for native libraries.
Where should I go next?
Actuator provides the probes this image should expose, and Docker containers for Go covers the same questions where the artefact is a static binary.