Home / Spring Boot / Production and Testing / Packaging, Docker and Deployment

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=prod

The 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.0

docker-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: secret

Environment 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=true
livenessProbe:
  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

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 failure

Complete 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

SettingRecommendation
Container memory limitHeap + metaspace + thread stacks + direct buffers. Plan about 25 to 40 percent above the heap
-XX:MaxRAMPercentage60 to 75 for the heap. Do not use 100
CPU limitThe JVM sizes GC and parallelism threads from CPUs. Very low limits (below 1 CPU) slow startup and GC
-XX:+ExitOnOutOfMemoryErrorCrash and restart instead of running in a broken state
Startup probeSlow starts (Hibernate, migrations) need a startup probe so liveness does not kill the pod

Startup and memory options

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.