Packaging, Docker and Deployment
Spring Boot builds a single executable jar. Put it in a container, pass configuration through environment variables, and add health probes and graceful shutdown, and it is ready for Docker or Kubernetes.
The executable (fat) jar
./mvnw clean package -DskipTests
java -jar target/shop-0.0.1-SNAPSHOT.jar
java -jar target/shop-0.0.1-SNAPSHOT.jar --server.port=9000 --spring.profiles.active=prodThe spring-boot-maven-plugin repackages your classes and all dependencies into one jar with an embedded Tomcat, so you only need Java on the server.
Simple Dockerfile
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY target/shop-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]Multi-stage build with layers (smaller, faster rebuilds)
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY . .
RUN ./mvnw -q clean package -DskipTests
RUN java -Djarmode=tools -jar target/*.jar extract --layers --destination /extracted
FROM eclipse-temurin:21-jre
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/ ./
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]Layers put rarely changing dependencies in one Docker layer and your code in another, so a small code change rebuilds only a tiny layer. The layer extraction command is java -Djarmode=tools in Boot 3.3 and newer (older versions used -Djarmode=layertools). Check the reference of your version.
Build an image without a Dockerfile (Buildpacks)
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=shop:1.0
docker run -p 8080:8080 -e SPRING_PROFILES_ACTIVE=prod shop:1.0docker-compose with PostgreSQL
services:
db:
image: postgres:16
environment:
POSTGRES_DB: shop
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
app:
image: shop:1.0
ports: ["8080:8080"]
depends_on: [db]
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
SPRING_DATASOURCE_USERNAME: app
SPRING_DATASOURCE_PASSWORD: secretEnvironment variables override properties because of relaxed binding: SPRING_DATASOURCE_URL becomes spring.datasource.url.
Kubernetes probes and graceful shutdown
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
management.endpoint.health.probes.enabled=truelivenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
initialDelaySeconds: 20
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }Graceful shutdown lets running requests finish before the application stops, so a rolling update does not cut users off.
Performance options
- Virtual threads (Java 21+): spring.threads.virtual.enabled=true lets blocking code scale to many concurrent requests.
- GraalVM native image: ./mvnw -Pnative native:compile gives very fast startup and low memory, but a slower build and some limits with reflection.
- Set the JVM memory for containers with -XX:MaxRAMPercentage instead of a fixed -Xmx.
- Lazy initialization (spring.main.lazy-initialization=true) speeds up startup but moves errors to the first use.
From source to running container: the workflow
git push ─► CI pipeline
1. mvn verify compile, unit tests, integration tests (Testcontainers), dependency scan
2. spring-boot:repackage fat jar with BOOT-INF layers
3. docker build layered image (dependencies layer cached, application layer small) or spring-boot:build-image (Buildpacks)
4. docker push registry, tagged with the git commit sha (never only "latest")
5. deploy Kubernetes rolling update, readiness probe gates traffic
6. observe metrics, logs, traces; automatic rollback on failureComplete Kubernetes deployment
apiVersion: v1
kind: ConfigMap
metadata: { name: shop-config }
data:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
---
apiVersion: v1
kind: Secret
metadata: { name: shop-secret }
stringData:
SPRING_DATASOURCE_USERNAME: app
SPRING_DATASOURCE_PASSWORD: change-me
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: shop }
spec:
replicas: 3
strategy: { type: RollingUpdate, rollingUpdate: { maxUnavailable: 0, maxSurge: 1 } }
selector: { matchLabels: { app: shop } }
template:
metadata: { labels: { app: shop } }
spec:
terminationGracePeriodSeconds: 45
containers:
- name: shop
image: registry.example.com/shop:1.4.2
ports: [{ containerPort: 8080 }]
envFrom:
- configMapRef: { name: shop-config }
- secretRef: { name: shop-secret }
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
resources:
requests: { cpu: "500m", memory: "768Mi" }
limits: { memory: "768Mi" }
startupProbe: { httpGet: { path: /actuator/health/liveness, port: 8080 }, failureThreshold: 30, periodSeconds: 2 }
livenessProbe: { httpGet: { path: /actuator/health/liveness, port: 8080 }, periodSeconds: 10 }
readinessProbe: { httpGet: { path: /actuator/health/readiness, port: 8080 }, periodSeconds: 5 }
lifecycle:
preStop: { exec: { command: ["sleep", "10"] } } # let the load balancer stop sending traffic before shutdown starts
---
apiVersion: v1
kind: Service
metadata: { name: shop }
spec:
selector: { app: shop }
ports: [{ port: 80, targetPort: 8080 }]Zero-downtime shutdown timeline
t=0 pod gets SIGTERM (Kubernetes removed it from the Service endpoints; the readiness state changes to REFUSING_TRAFFIC)
t=0-10 preStop sleep: the load balancer and proxies finish updating so new requests stop arriving
t=10 Spring starts graceful shutdown (server.shutdown=graceful): the web server stops accepting NEW requests, running requests finish
(spring.lifecycle.timeout-per-shutdown-phase=30s is the maximum wait)
t<=40 lifecycle beans stop, message consumers close, the connection pool closes
t=45 terminationGracePeriodSeconds ends: SIGKILL if the process is still alive
Set: terminationGracePeriodSeconds > preStop + Spring timeout. Otherwise requests are cut off during every deployment.JVM in containers: sizing
| Setting | Recommendation |
|---|---|
| Container memory limit | Heap + metaspace + thread stacks + direct buffers. Plan about 25 to 40 percent above the heap |
| -XX:MaxRAMPercentage | 60 to 75 for the heap. Do not use 100 |
| CPU limit | The JVM sizes GC and parallelism threads from CPUs. Very low limits (below 1 CPU) slow startup and GC |
| -XX:+ExitOnOutOfMemoryError | Crash and restart instead of running in a broken state |
| Startup probe | Slow starts (Hibernate, migrations) need a startup probe so liveness does not kill the pod |
Startup and memory options
- Class Data Sharing (AppCDS) and the newer AOT cache shorten startup and reduce memory. Boot supports them through the extracted layered jar. Check the packaging documentation of your version for the exact commands.
- GraalVM native image (./mvnw -Pnative native:compile, or buildpacks with BP_NATIVE_IMAGE=true) gives startup in tens of milliseconds and low memory, at the cost of long builds, closed-world limits (reflection needs hints, Spring generates most of them) and different debugging.
- Virtual threads and a small Hikari pool are often enough to solve throughput problems without native images.
Common mistake: Baking passwords and environment-specific URLs into the image. Build one image and inject configuration through environment variables or secrets at runtime.
Interview questions
Jar or WAR?
Use the executable jar for almost every new project. Use WAR only if you must deploy into an existing external application server.
How do I reduce the image size?
Use a JRE base image, the layered jar, and multi-stage builds. jlink or native images reduce it further.
Why does my container get OOM killed?
The JVM may size its heap from the host memory. Set a container memory limit and use -XX:MaxRAMPercentage so the JVM respects it.
Why use a preStop sleep in Kubernetes?
Removing a pod from the load balancer takes a few seconds. The sleep keeps the pod serving until no new requests arrive, so graceful shutdown starts on a quiet pod.
Why not tag images only as latest?
Latest is not reproducible and rollback is unclear. Tag with the version or commit sha so every deployment is traceable and can be rolled back.