Skip to content
CalliCoder

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.