Skip to main content
Java · lesson 58 of 66
Open the Java page →
Java intermediate Lesson 58 of 66

Also part of: Docker

Dockerizing Spring Boot Applications

Prepare a Spring Boot 3 app for containers: layered jars, env-var config, JVM memory flags, health probes and graceful shutdown, plus a Dockerfile.

By SkillByExample · Updated

A container runs your jar, but whether it runs well depends on things you can prepare and test without Docker at all: how the jar is layered, how configuration arrives, how much memory the JVM takes, and what happens when the container is stopped. Every command below was run for real against a Spring Boot 3.5 web app with Actuator. No Docker daemon was available, so the Dockerfile and compose.yaml at the end are configuration shown for reference — they were not built or run here, and no docker output appears in this lesson.

The app and the jar

@RestController
public class HelloController {

    private final String greeting;
    private final Environment env;

    public HelloController(@Value("${app.greeting}") String greeting, Environment env) {
        this.greeting = greeting;
        this.env = env;
    }

    record Info(String greeting, String[] profiles, long maxHeapMb, int cpus) {}

    @GetMapping("/api/info")
    public Info info() {
        return new Info(greeting, env.getActiveProfiles(),
                Runtime.getRuntime().maxMemory() / (1024 * 1024),
                Runtime.getRuntime().availableProcessors());
    }

    @GetMapping("/api/slow")
    public String slow() throws InterruptedException {
        Thread.sleep(3000);                                  // a request still in flight at shutdown
        return "slow request finished";
    }
}
# application.properties
spring.application.name=app
app.greeting=Hello
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
management.endpoint.health.probes.enabled=true

# application-docker.properties
app.greeting=Hello from a container
$ mvn -q -B clean package -DskipTests
$ ls -lh target/*.jar | awk '{print $5, $9}'
23M target/app-0.0.1-SNAPSHOT.jar

/api/info reports what the JVM actually got — heap and CPUs — which is what changes inside a container. The jar is 23 MB, and almost none of it is our code.

Layers: what a Dockerfile should copy

$ java -Djarmode=tools -jar target/app-0.0.1-SNAPSHOT.jar list-layers
dependencies
spring-boot-loader
snapshot-dependencies
application
$ java -Djarmode=tools -jar target/app-0.0.1-SNAPSHOT.jar extract --layers --launcher --destination extracted
$ du -sh extracted/application extracted/dependencies extracted/snapshot-dependencies extracted/spring-boot-loader
88K	extracted/application
23M	extracted/dependencies
4.0K	extracted/snapshot-dependencies
676K	extracted/spring-boot-loader
$ mkdir merged
$ cp -r extracted/dependencies/. extracted/spring-boot-loader/. extracted/snapshot-dependencies/. extracted/application/. merged/
$ cd merged && java org.springframework.boot.loader.launch.JarLauncher > app.log 2>&1 &
$ grep -E 'Starting|Started' app.log
2026-10-07T23:25:03.960Z  INFO 9911 --- [app] [           main] com.example.demo.DemoApplication         : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.10 with PID 9911 (/tmp/sb/dock/merged/BOOT-INF/classes started by root in /tmp/sb/dock/merged)
2026-10-07T23:25:05.023Z  INFO 9911 --- [app] [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2026-10-07T23:25:05.024Z  INFO 9911 --- [app] [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.55]
2026-10-07T23:25:05.726Z  INFO 9911 --- [app] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 2.138 seconds (process running for 2.417)
$ curl -s -w '\n' localhost:8080/api/info
{"greeting":"Hello","profiles":[],"maxHeapMb":3422,"cpus":4}

The jar knows how to split itself (-Djarmode=tools, Spring Boot 3.3+): 23 MB of dependencies that change rarely, 88 KB of application classes that change on every commit. Copying the four layers on top of each other — exactly what the Dockerfile below does with four COPY lines — rebuilds a runnable app, started with JarLauncher. Docker caches each COPY as an image layer, so ordering them from least to most volatile means a code change rebuilds and pushes kilobytes, not the whole 23 MB.

Configuration from the environment

A container is configured with environment variables. Here the profile, port and JVM flags all arrive that way, as docker run -e or a Compose file would pass them:

$ SPRING_PROFILES_ACTIVE=docker SERVER_PORT=9090 JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75 -XX:ActiveProcessorCount=2" java -jar target/app-0.0.1-SNAPSHOT.jar
Picked up JAVA_TOOL_OPTIONS: -XX:MaxRAMPercentage=75 -XX:ActiveProcessorCount=2
2026-10-07T22:53:57.406Z  INFO 14189 --- [app] [           main] com.example.demo.DemoApplication         : The following 1 profile is active: "docker"
...
2026-10-07T22:53:58.921Z  INFO 14189 --- [app] [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 9090 (http) with context path '/'
2026-10-07T22:53:58.933Z  INFO 14189 --- [app] [           main] com.example.demo.DemoApplication         : Started DemoApplication in 1.948 seconds (process running for 2.342)
$ curl -s -w '\n' localhost:9090/api/info
{"greeting":"Hello from a container","profiles":["docker"],"maxHeapMb":10264,"cpus":2}

SPRING_PROFILES_ACTIVE loaded application-docker.properties; SERVER_PORT is server.port in environment-variable form. Nothing was rebuilt — one image runs in every environment, and secrets such as a database password arrive the same way instead of being baked into the image. JAVA_TOOL_OPTIONS is read by the JVM itself (note its Picked up line), a convenient way to tune the JVM without changing the image’s ENTRYPOINT.

JVM memory inside a container

$ java -XshowSettings:system -version 2>&1 | grep -E 'Provider|Effective CPU|Memory Limit'
    Provider: cgroupv1
    Effective CPU Count: 4
    Memory Limit: 13.36G
$ java -XX:+PrintFlagsFinal -version 2>&1 | grep -E ' MaxHeapSize | MaxRAMPercentage '
   size_t MaxHeapSize                              = 3588227072                                {product} {ergonomic}
   double MaxRAMPercentage                         = 25.000000                                 {product} {default}
$ java -XX:MaxRAMPercentage=75 -XX:+PrintFlagsFinal -version 2>&1 | grep -E ' MaxHeapSize | MaxRAMPercentage '
   size_t MaxHeapSize                              = 10762584064                               {product} {ergonomic}
   double MaxRAMPercentage                         = 75.000000                                 {product} {command line}

These commands ran inside a cgroup-limited Linux environment, and the JVM read its limits from cgroups: 13.36 GB and 4 CPUs, not the host’s totals. By default the heap is only 25% of the limit (3.3 GB of 13.36 GB) — sensible on a shared server, wasteful in a container that runs nothing but this JVM. -XX:MaxRAMPercentage=75 raised it to 10 GB, matching the maxHeapMb the app reported above. Use a percentage rather than a fixed -Xmx so the heap follows whatever memory limit the container is given, and leave the remaining quarter for metaspace, thread stacks and direct buffers.

Health probes

With management.endpoint.health.probes.enabled=true, Actuator exposes the two groups orchestrators poll (they are enabled automatically when Spring Boot detects Kubernetes). From the same run:

$ curl -s -w '\n' localhost:9090/actuator/health
{"status":"UP","groups":["liveness","readiness"]}
$ curl -s -w '\n' localhost:9090/actuator/health/liveness
{"status":"UP"}
$ curl -s -w '\n' localhost:9090/actuator/health/readiness
{"status":"UP"}

Liveness answers “is the process healthy, or should it be restarted?”; readiness answers “should it receive traffic right now?” — it reports OUT_OF_SERVICE during startup and shutdown. Keep external dependencies such as the database out of liveness, or a database outage would make the orchestrator restart every healthy instance.

Graceful shutdown

docker stop and Kubernetes send SIGTERM, wait, then SIGKILL. This script reproduces that with a request still in flight:

#!/bin/bash
# start the app, send a slow request, then stop the app the way `docker stop` does
java -jar target/app-0.0.1-SNAPSHOT.jar "$@" > app.log 2>&1 &
APP=$!
until curl -s localhost:8080/actuator/health/readiness > /dev/null; do sleep 0.5; done

curl -s -w ' (curl exit %{exitcode})\n' localhost:8080/api/slow &    # takes 3 seconds
sleep 1
kill -TERM $APP                                                        # SIGTERM
wait
grep -o 'Commencing graceful shutdown.*\|Graceful shutdown complete' app.log
$ bash shutdown-demo.sh
slow request finished (curl exit 0)
Commencing graceful shutdown. Waiting for active requests to complete
Graceful shutdown complete
$ bash shutdown-demo.sh --server.shutdown=immediate
 (curl exit 52)

With server.shutdown=graceful (the default since Spring Boot 3.4, set explicitly here) the server stopped accepting new connections on SIGTERM but let the 3-second request finish, waiting up to spring.lifecycle.timeout-per-shutdown-phase. With immediate, the request in progress was cut off: curl’s exit code 52 means the server closed the connection with no response. Keep the shutdown timeout below the orchestrator’s grace period (10 s for docker stop, 30 s in Kubernetes), and make sure the JVM is PID 1 or receives signals — the exec-form ENTRYPOINT below does that.

The Dockerfile (configuration, not executed here)

A multi-stage build compiles in a JDK image and runs on a smaller JRE image, copying the layers extracted above in order of volatility:

# ---- build stage ----
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw -q dependency:go-offline          # cached until pom.xml changes
COPY src/ src/
RUN ./mvnw -q package -DskipTests \
 && java -Djarmode=tools -jar target/*.jar extract --layers --launcher --destination /extracted

# ---- runtime stage ----
FROM eclipse-temurin:21-jre
RUN useradd --system --uid 1001 spring
USER spring
WORKDIR /app
COPY --from=build /extracted/dependencies/ ./
COPY --from=build /extracted/spring-boot-loader/ ./
COPY --from=build /extracted/snapshot-dependencies/ ./
COPY --from=build /extracted/application/ ./
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
# .dockerignore — keep the build context small
target/
.git/
.idea/
*.iml

The runtime stage starts the same JarLauncher that ran from merged/ above, as a non-root user, with the heap flag from the memory section. The JSON-array ENTRYPOINT makes java PID 1, so it receives SIGTERM directly and the graceful shutdown shown above applies. Without writing a Dockerfile, ./mvnw spring-boot:build-image produces a similar layered image with Cloud Native Buildpacks; it also needs a Docker daemon.

Docker Compose for local development (configuration, not executed here)

# compose.yaml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: docker
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
      SPRING_DATASOURCE_USERNAME: shop
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD}       # from the shell or a .env file
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop"]
      interval: 5s
      retries: 10

Every setting reaches Spring Boot as an environment variable, exactly like SERVER_PORT in the run above; db is the service’s host name on Compose’s network. depends_on with service_healthy holds the app back until PostgreSQL accepts connections. This file assumes the PostgreSQL driver is on the classpath.

Practice

  1. Give the JVM half the container’s memory instead of 75%, and predict maxHeapMb before running.
Solution
$ JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=50" java -jar target/app-0.0.1-SNAPSHOT.jar &
$ curl -s -w '\n' localhost:8080/api/info
{"greeting":"Hello","profiles":[],"maxHeapMb":6844,"cpus":4}

Half of 13.36 GB is about 6.7 GB. (The app’s startup log, including the Picked up JAVA_TOOL_OPTIONS line, is omitted.)

  1. Prove that a code change only invalidates the application layer. Print a checksum per extracted layer, change a string in HelloController, rebuild, and compare.
Solution
#!/bin/bash
# layer-hashes.sh: print one checksum per layer of the extracted jar
rm -rf extracted
java -Djarmode=tools -jar target/app-0.0.1-SNAPSHOT.jar extract --layers --launcher --destination extracted
for layer in dependencies spring-boot-loader snapshot-dependencies application; do
  printf '%-22s %s\n' "$layer" "$(cd extracted/$layer && find . -type f | sort | xargs -r cat | md5sum | cut -c1-12)"
done
$ bash layer-hashes.sh
dependencies           c70f2e47aa5d
spring-boot-loader     59db8330e657
snapshot-dependencies  d41d8cd98f00
application            5b3c9a8f7a7d
$ sed -i 's/slow request finished/slow request done/' src/main/java/com/example/demo/HelloController.java
$ mvn -q -B package -DskipTests
$ bash layer-hashes.sh
dependencies           c70f2e47aa5d
spring-boot-loader     59db8330e657
snapshot-dependencies  d41d8cd98f00
application            7dfe24e2d2be

Only application changed, so in the Dockerfile above only its COPY layer would be rebuilt; the 23 MB dependencies layer would come from cache. (d41d8cd98f00 is the MD5 of empty input — this project has no snapshot dependencies.)

  1. In shutdown-demo.sh, what would happen with server.shutdown=graceful if /api/slow took 60 seconds and the container ran under docker stop’s default 10-second grace period?
Solution

Spring would wait for the request, but after 10 seconds Docker sends SIGKILL and the JVM dies immediately — the client sees the same dropped connection as with immediate. Graceful shutdown only helps within the orchestrator’s grace period: set spring.lifecycle.timeout-per-shutdown-phase below it (or raise it with docker stop --time / Kubernetes terminationGracePeriodSeconds), and keep request handling short.

Frequently Asked Questions

Why use a layered jar in a Docker image?
A Spring Boot fat jar mixes about 23 MB of dependencies with a few kilobytes of your own classes, so copying it as one file means every code change produces a new image layer containing everything. Extracting it with java -Djarmode=tools extract --layers and copying each layer separately lets Docker reuse the unchanged dependency layers from cache, so rebuilds and pushes only move your classes.
How do I configure a Spring Boot app running in a container?
Use environment variables. Spring Boot maps SPRING_PROFILES_ACTIVE, SERVER_PORT, SPRING_DATASOURCE_URL and any other property to its upper-case, underscore form, and they override application.properties packaged in the jar. Pass them with docker run -e, a Compose environment section or a Kubernetes manifest, and keep secrets out of the image.
How much heap does the JVM use inside a container?
Modern JVMs detect the container's memory limit from cgroups and by default give the heap 25 percent of it, which wastes memory in a container that runs only the JVM. Set -XX:MaxRAMPercentage, commonly to 75, through JAVA_TOOL_OPTIONS or the ENTRYPOINT, so the heap scales with whatever limit the container is given.
What does server.shutdown=graceful do?
When the JVM receives SIGTERM, which docker stop and Kubernetes send, Spring Boot stops accepting new requests but lets in-flight requests finish before closing, for up to spring.lifecycle.timeout-per-shutdown-phase. Without it, requests being processed at shutdown are cut off and clients see a dropped connection. Graceful is the default since Spring Boot 3.4.
Do I need a Dockerfile to containerize Spring Boot?
No. mvn spring-boot:build-image builds an OCI image with Cloud Native Buildpacks, already layered and with memory settings calculated for the container, without a Dockerfile. A hand-written multi-stage Dockerfile gives you full control over the base image and build steps; both need a Docker daemon to build.