Java · lesson 58 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also 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
- Give the JVM half the container’s memory instead of 75%, and predict
maxHeapMbbefore 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.)
- Prove that a code change only invalidates the
applicationlayer. Print a checksum per extracted layer, change a string inHelloController, 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.)
- In
shutdown-demo.sh, what would happen withserver.shutdown=gracefulif/api/slowtook 60 seconds and the container ran underdocker 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.