00:00

How to Deploy a Spring Boot Application on Kubernetes

Last updated:

Kubernetes is a platform that runs your containers for you. It starts them, restarts them when they crash, spreads traffic across copies and replaces old versions with new ones without downtime. Knowing how to deploy a Spring Boot application on Kubernetes is now a core skill, because most companies run their Java services this way.

This tutorial takes the catalog-service image from the Docker with Spring Boot tutorial and runs it on a local Kubernetes cluster. You will write the YAML files for the app and its PostgreSQL database, wire up health probes and graceful shutdown, pass in configuration and secrets, and roll out a new version with zero downtime.

1. What Is Kubernetes?

Kubernetes (often written K8s) is a container orchestrator. You describe the state you want in YAML files, for example "two copies of catalog-service, version 1.0.0". Kubernetes then works continuously to make the cluster match that description.

A cluster is a group of machines called nodes. A control plane decides what runs where, and an agent on each node, the kubelet, starts the containers and checks their health.

The key idea is that you don't run commands like "start a container". You declare the result you want, and Kubernetes keeps it true. If a node dies, its containers are recreated on another node.

2. When a Spring Boot App Needs Kubernetes

A single Spring Boot service on one server doesn't need Kubernetes. It starts to pay off when you have:

  • Several copies of a service. You want traffic spread across them, and a crashed copy replaced automatically.
  • Frequent releases. You want to replace versions gradually, with an easy rollback.
  • Many services. You want one standard way to deploy, configure and monitor all of them.
  • Uneven load. You want to add copies at busy times and remove them later.

Section 11 compares good and poor use cases in more detail.

3. How a Spring Boot App Runs Inside a Kubernetes Cluster

Spring Boot catalog-service running on KubernetesA client reaches the catalog-service Service, which load-balances to two Pods managed by a Deployment. A ConfigMap provides environment variables and a Secret provides files to the Pods. The Pods connect to the postgres Service, backed by the postgres-0 StatefulSet Pod and a PersistentVolumeClaim. You (curl) via port-forward Kubernetes cluster (kind) ConfigMap catalog-config Service · port 80 catalog-service Secret catalog-db Deployment · replicas: 2 Pod catalog-service :8080 · probes Pod catalog-service :8080 · probes env files Service · port 5432 postgres JDBC StatefulSet Pod postgres-0 PVC · 1Gi data
Figure 1: requests reach the app through a Service, which spreads them across the Pods; the Pods get settings from a ConfigMap and a Secret.

Follow a request through Figure 1:

  1. A request arrives at the Service catalog-service. A Service is a stable name and IP address that sits in front of a changing set of Pods.
  2. The Service forwards it to one of the Pods that are currently ready. A Pod is the smallest unit Kubernetes runs; here, each Pod holds one catalog-service container.
  3. The Deployment makes sure two Pods exist at all times. It also controls how they are replaced during an update.
  4. Each Pod reads normal settings as environment variables from a ConfigMap, and the database credentials as files from a Secret.
  5. The app reaches PostgreSQL through a second Service, postgres. Because that Service has a fixed name, the JDBC URL never changes.

4. Setup: Tools and a Local Cluster

ToolPurpose
JDK 21+ and Maven wrapperBuilds the jar
DockerBuilds the image
kindRuns a Kubernetes cluster inside Docker on your machine
kubectlThe command-line client for Kubernetes

Create a cluster named shop:

Terminal
kind create cluster --name shop
kubectl cluster-info --context kind-shop

The application itself is the catalog-service from the Docker tutorial. It uses Spring Boot 4.1 with Java 21, spring-boot-starter-webmvc, spring-boot-starter-data-jpa, spring-boot-starter-actuator and the PostgreSQL driver. Its layered Dockerfile builds the image javabytechie/catalog-service:1.0.0.

Version note:
  • Graceful shutdown: on by default since Spring Boot 3.4. Before that, you had to set server.shutdown=graceful.
  • Web starter: Spring Boot 4 apps use spring-boot-starter-webmvc. Many 3.x projects still use spring-boot-starter-web.
  • Layer extraction: the Dockerfile's -Djarmode=tools mode replaces the old layertools mode, which Spring Boot 4.1 removed.
  • preStop sleep: the built-in sleep action in Section 7.4 needs Kubernetes 1.32 or later.

5. Core Kubernetes Objects for a Spring Boot App

5.1 Pod

A Pod wraps one or more containers that share a network address. You rarely create Pods directly. A Deployment creates them for you, because a lone Pod is not recreated if its node dies.

5.2 Deployment and ReplicaSet

A Deployment describes the Pods you want: the image, the number of copies (replicas), and the resources and probes. Behind the scenes it creates a ReplicaSet, which keeps the right number of Pods running. When you change the image, the Deployment creates a new ReplicaSet and gradually shifts Pods to it. This is a rolling update.

5.3 Service

Pods come and go, and each new Pod gets a new IP address. A Service gives them one stable DNS name, such as catalog-service, and sends traffic only to Pods that pass their readiness probe. The default type, ClusterIP, is reachable only inside the cluster.

5.4 ConfigMap and Secret

A ConfigMap holds non-sensitive settings, such as the database URL. A Secret holds sensitive values, such as passwords. Both can be passed into a container as environment variables or as files.

One warning: by default, Secret values are only base64-encoded, and they are stored unencrypted in the cluster's database (etcd). Restrict who can read Secrets, and turn on encryption at rest in real clusters.

5.5 Probes

A probe is a health check that the kubelet runs against your container. Kubernetes has three kinds:

ProbeQuestion it asksWhat happens on failureSpring Boot endpoint used here
StartupHas the app finished starting?Container is restarted; the other probes wait until it passes/actuator/health/liveness
LivenessIs the app still working, or stuck?Container is restarted/actuator/health/liveness
ReadinessCan the app take traffic right now?Pod is removed from the Service; it is not restarted/actuator/health/readiness

By default, a probe runs every 10 seconds (periodSeconds), times out after 1 second and fails after 3 misses in a row.

6. How Spring Boot Supports Kubernetes

6.1 Platform Detection

Spring Boot checks the environment for the *_SERVICE_HOST and *_SERVICE_PORT variables that Kubernetes injects into every container. If it finds them, it treats the app as running on Kubernetes and adjusts some defaults, such as turning on the probe health groups. You can override the detection with spring.main.cloud-platform.

6.2 Liveness and Readiness Health Groups

Spring Boot Actuator tracks two internal states:

  • LivenessState: CORRECT or BROKEN.
  • ReadinessState: ACCEPTING_TRAFFIC or REFUSING_TRAFFIC.

It exposes them as health groups at /actuator/health/liveness and /actuator/health/readiness. The app only becomes ready after all ApplicationRunner and CommandLineRunner beans have run, so a Pod gets no traffic while it is still warming up.

To watch these states change, add a small listener:

AvailabilityLogger.java
package com.javabytechie.catalog.health;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.LivenessState;
import org.springframework.boot.availability.ReadinessState;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class AvailabilityLogger {

    private static final Logger log = LoggerFactory.getLogger(AvailabilityLogger.class);

    @EventListener
    public void onLivenessChange(AvailabilityChangeEvent<LivenessState> event) {
        log.info("Liveness state is now {}", event.getState());
    }

    @EventListener
    public void onReadinessChange(AvailabilityChangeEvent<ReadinessState> event) {
        log.info("Readiness state is now {}", event.getState());
    }
}
Console
INFO c.j.catalog.health.AvailabilityLogger : Liveness state is now CORRECT
INFO c.j.catalog.health.AvailabilityLogger : Readiness state is now ACCEPTING_TRAFFIC

Your own code can change readiness too. For example, if the app is overloaded, it can call AvailabilityChangeEvent.publish(context, ReadinessState.REFUSING_TRAFFIC) to stop receiving traffic for a while.

Keep the liveness group free of external checks. Suppose liveness included the database check. A short database outage would then make every Pod fail liveness at the same time, and Kubernetes would restart all of them, turning a small problem into a full outage. If you want readiness to depend on a dependency, add it only to the readiness group, for example management.endpoint.health.group.readiness.include=readinessState,db. Even then, remember that this removes every Pod from the Service while the database is down.

The Spring Boot Actuator tutorial covers health groups and custom health indicators in depth.

6.3 Graceful Shutdown and the preStop Hook

When Kubernetes deletes a Pod, two things start at the same moment:

  1. The control plane removes the Pod from the Service's endpoint list. Load balancers and other nodes take a few seconds to notice.
  2. The kubelet begins stopping the container.

If the app shuts down instantly, requests still being routed to it during those seconds fail. The fix is a short preStop pause, which keeps the app serving while the endpoint removal spreads through the cluster:

Timeline of a Spring Boot Pod shutting down on KubernetesAt time zero the Pod is deleted. In parallel, the control plane removes the Pod from Service endpoints while the kubelet runs a 10-second preStop sleep. At 10 seconds the kubelet sends SIGTERM and Spring Boot drains in-flight requests for up to 30 seconds. At 45 seconds the grace period ends and any remaining process is killed with SIGKILL. Control plane kubelet Spring Boot app t = 0 · Pod deleted Removed from endpoints new traffic stops preStop: sleep 10s → SIGTERM Graceful shutdown: finish in-flight requests up to 30s, then close the DB pool and exit 45s · SIGKILL if still running 0s 10s 40s 45s
Figure 2: the preStop pause covers the endpoint-removal delay, then Spring Boot drains requests before the 45-second grace period runs out.

When SIGTERM (the polite "please stop" signal) arrives, Spring Boot's graceful shutdown stops accepting new requests and gives in-flight ones time to finish. It waits up to 30 seconds per shutdown phase by default (spring.lifecycle.timeout-per-shutdown-phase), then the JVM exits.

The whole sequence must fit inside terminationGracePeriodSeconds, which defaults to 30 seconds and also counts the preStop time. That is why the example sets 45 seconds: a 10-second pause, plus 30 seconds to drain, plus 5 seconds of buffer. If anything is still running when the grace period ends, the kubelet sends SIGKILL, and the container ends with exit code 137.

6.4 Reading Secrets with Configuration Trees

Kubernetes can mount a Secret as a folder, with one file per key. Spring Boot reads such a folder as a configuration tree: each file name becomes a property name, and the file's content becomes its value. Import it like this:

application.properties
spring.config.import=optional:configtree:/etc/secrets/catalog/

The optional: prefix lets the app still start on your laptop, where the folder doesn't exist. Files are safer than environment variables for secrets, because environment variables tend to leak into crash reports and debug output. For per-environment settings that aren't secret, Spring Boot profiles remain a good fit.

7. Deploy a Spring Boot Application on Kubernetes Step by Step

The Java code from the Docker tutorial stays exactly as it is. Only application.properties gains one line, and the new AvailabilityLogger from Section 6.2 is optional. The Kubernetes files go in a k8s/ folder:

Project layout
catalog-service/
├── Dockerfile
├── pom.xml
├── src/main/...
└── k8s/
    ├── postgres.yaml
    ├── catalog-config.yaml
    ├── catalog-deployment.yaml
    └── catalog-service.yaml

7.1 Application Properties

application.properties
spring.application.name=catalog-service
spring.datasource.url=jdbc:postgresql://localhost:5432/catalog
spring.datasource.username=catalog
spring.datasource.password=catalog
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
management.endpoints.web.exposure.include=health
management.endpoint.health.probes.enabled=true
spring.config.import=optional:configtree:/etc/secrets/catalog/

The localhost values keep local runs working. In the cluster, the ConfigMap overrides the URL, and the mounted Secret overrides the username and password: values from an imported file take precedence over the file that imports them. Setting probes.enabled explicitly makes the health groups available even outside Kubernetes. ddl-auto=update keeps the example short. In production, manage the schema with Flyway database migrations.

7.2 PostgreSQL

This setup is good for learning. In production, prefer a managed database service.

k8s/postgres.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  clusterIP: None
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:17
          ports:
            - containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: catalog
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: catalog-db
                  key: username
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: catalog-db
                  key: password
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "catalog", "-d", "catalog"]
            periodSeconds: 5
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 1Gi
  • StatefulSet: a StatefulSet is like a Deployment, but each Pod keeps a stable name (postgres-0) and its own storage.
  • Storage: volumeClaimTemplates requests a 1 GB PersistentVolumeClaim, a request for disk space that outlives the Pod.
  • Service: clusterIP: None makes the Service headless, so the name postgres resolves straight to the database Pod.

7.3 ConfigMap

k8s/catalog-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: catalog-config
data:
  SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/catalog

Spring Boot maps SPRING_DATASOURCE_URL to spring.datasource.url. Environment variables override application.properties.

7.4 Deployment

k8s/catalog-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: catalog-service
  labels:
    app: catalog-service
spec:
  replicas: 2
  selector:
    matchLabels:
      app: catalog-service
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: catalog-service
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: catalog-service
          image: javabytechie/catalog-service:1.0.0
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
          envFrom:
            - configMapRef:
                name: catalog-config
          volumeMounts:
            - name: db-credentials
              mountPath: /etc/secrets/catalog
              readOnly: true
          resources:
            requests:
              cpu: 250m
              memory: 768Mi
            limits:
              memory: 768Mi
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 5
            failureThreshold: 30
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            periodSeconds: 5
            failureThreshold: 3
          lifecycle:
            preStop:
              sleep:
                seconds: 10
      volumes:
        - name: db-credentials
          secret:
            secretName: catalog-db
            items:
              - key: username
                path: spring.datasource.username
              - key: password
                path: spring.datasource.password

What each part does:

  • maxSurge: 1 and maxUnavailable: 0: during an update, one extra Pod starts first, and an old Pod stops only after the new one is ready. Capacity never drops.
  • imagePullPolicy: IfNotPresent with a fixed tag: the node uses the image you loaded instead of pulling from a registry.
  • resources: the scheduler reserves 250 millicores (a quarter of a CPU) and 768 MiB of memory for the Pod. The memory limit equals the request, so the Pod is never squeezed by its neighbours. The image already sets -XX:MaxRAMPercentage=75, so the JVM heap gets about 576 MiB, and the rest is left for metaspace and threads. There is no CPU limit, so the JVM can use spare CPU during its busy startup.
  • startupProbe: gives the app up to 150 seconds to start (5s × 30) before liveness checks begin. Without it, a slow first start could be mistaken for a hang.
  • Secret volume: the items list mounts the Secret keys username and password as files named spring.datasource.username and spring.datasource.password. This is exactly what the configuration tree from Section 6.4 expects.
  • preStop.sleep: the pause from Figure 2. On Kubernetes older than 1.32, use exec: command: ["sh", "-c", "sleep 10"] instead. That works with this image because its Temurin base includes a shell.

7.5 Service

k8s/catalog-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: catalog-service
spec:
  selector:
    app: catalog-service
  ports:
    - name: http
      port: 80
      targetPort: http

Other Pods can now call http://catalog-service/api/products. The Service listens on port 80 and forwards to the container port named http.

7.6 Build, Load and Apply

Terminal
# 1. Build the jar and the image (Dockerfile from the Docker tutorial)
./mvnw clean package
docker build -t javabytechie/catalog-service:1.0.0 .

# 2. Copy the image into the kind cluster's node
kind load docker-image javabytechie/catalog-service:1.0.0 --name shop

# 3. Create the Secret from the command line, so it is never committed to Git
kubectl create secret generic catalog-db \
  --from-literal=username=catalog \
  --from-literal=password='S3cure-Catalog-Pass'

# 4. Start PostgreSQL first and wait until it is ready
kubectl apply -f k8s/postgres.yaml
kubectl wait --for=condition=Ready pod/postgres-0 --timeout=120s

# 5. Deploy the app
kubectl apply -f k8s/
kubectl rollout status deployment/catalog-service
Console
secret/catalog-db created
service/postgres created
statefulset.apps/postgres created
pod/postgres-0 condition met
configmap/catalog-config created
deployment.apps/catalog-service created
service/catalog-service created
service/postgres unchanged
statefulset.apps/postgres unchanged
deployment "catalog-service" successfully rolled out

PostgreSQL starts first because the app connects to the database at startup. If it started first, it would crash and restart until the database came up.

Terminal
kubectl get pods
Console
NAME                               READY   STATUS    RESTARTS   AGE
catalog-service-6c9f7d8b54-kx2vp   1/1     Running   0          52s
catalog-service-6c9f7d8b54-tq7wd   1/1     Running   0          52s
postgres-0                         1/1     Running   0          95s

READY 1/1 means the readiness probe passes, so the Service is sending traffic to that Pod. Pod name suffixes will differ on your machine.

7.7 Call the API

The Service is internal to the cluster. Forward a local port to it:

Terminal
kubectl port-forward service/catalog-service 8080:80
Console
Forwarding from 127.0.0.1:8080 -> 8080

In a second terminal:

Terminal
curl -i -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"sku":"KB-100","name":"Mechanical Keyboard","price":89.00}'

curl http://localhost:8080/api/products/KB-100
curl -i http://localhost:8080/api/products/XX-1
Console
HTTP/1.1 201
{"sku":"KB-100","name":"Mechanical Keyboard","price":89.00}

{"sku":"KB-100","name":"Mechanical Keyboard","price":89.00}

HTTP/1.1 404
{"timestamp":"2026-11-01T10:15:42.318Z","status":404,"error":"Not Found","path":"/api/products/XX-1"}

An unknown SKU returns 404 Not Found through ProductNotFoundException, just as it did in Docker. The product data survives Pod restarts because it lives in PostgreSQL's PersistentVolumeClaim.

8. Rolling Updates, Rollbacks and Zero-Downtime Checks

To release version 1.0.1:

  1. Set <version>1.0.1</version> in pom.xml.
  2. Rebuild, and load the new image into kind.
  3. Change the image tag in k8s/catalog-deployment.yaml to javabytechie/catalog-service:1.0.1.
  4. Apply the files again.

Editing the YAML file, rather than running kubectl set image, keeps the files in Git matching what actually runs.

Terminal
./mvnw clean package
docker build -t javabytechie/catalog-service:1.0.1 .
kind load docker-image javabytechie/catalog-service:1.0.1 --name shop
kubectl apply -f k8s/catalog-deployment.yaml
kubectl rollout status deployment/catalog-service

To prove there is no downtime, send requests from inside the cluster while the update runs. Don't test through kubectl port-forward: it connects to a single Pod, so the test would break as soon as that Pod is replaced.

Terminal
kubectl run traffic --rm -it --image=curlimages/curl --restart=Never -- \
  sh -c 'while true; do curl -s -o /dev/null -w "%{http_code}\n" http://catalog-service/api/products; sleep 0.2; done'
Console
200
200
200
...

You should see only 200 lines while the Pods are swapped. If you remove the preStop block and repeat the test, you may see occasional failed requests during the rollout. That is the race from Section 6.3.

If the new version misbehaves, roll back to the previous ReplicaSet:

Terminal
kubectl rollout history deployment/catalog-service
kubectl rollout undo deployment/catalog-service
Console
deployment.apps/catalog-service rolled back

After a rollback, change the tag in the YAML file back to 1.0.0, so the next kubectl apply doesn't redeploy the bad version.

9. Configuration Options Reference

SettingWhereDefaultValue in this example
replicasDeployment12
maxSurge / maxUnavailableDeployment strategy25% / 25%1 / 0
terminationGracePeriodSecondsPod spec3045
periodSeconds / timeoutSeconds / failureThresholdEach probe10 / 1 / 3Set per probe
preStop.sleep.secondsContainer lifecyclenone10
server.shutdownSpring propertygraceful (since 3.4)default
spring.lifecycle.timeout-per-shutdown-phaseSpring property30sdefault
management.endpoint.health.probes.enabledSpring propertyon automatically on Kubernetestrue
management.endpoint.health.probes.add-additional-pathsSpring propertyfalsenot used; when true, also serves /livez and /readyz on the main port
spring.main.cloud-platformSpring propertyauto-detectednot set

10. Common Pitfalls and How to Fix Them

SymptomLikely causeFix
ErrImagePull or ImagePullBackOffThe image isn't in the kind node, or the tag is :latest, which makes Kubernetes pull it from a registryRun kind load docker-image ..., use a fixed tag and imagePullPolicy: IfNotPresent
CrashLoopBackOffThe app exits at startup, often because the database is unreachable or a property is wrongCheck kubectl logs <pod> --previous and confirm the ConfigMap URL and Secret
OOMKilled, or exit code 137 under loadThe heap plus native memory exceeds the memory limitKeep MaxRAMPercentage around 75 and raise the limit if needed
Pod Running but READY 0/1Readiness fails: wrong path or port, or the endpoint isn't exposedRun kubectl describe pod <pod>; check that management.endpoints.web.exposure.include contains health
All Pods restart when the database blipsA database check was added to the liveness groupKeep liveness to livenessState only
Failed requests during every rolloutNo preStop pause, or maxUnavailable greater than 0 with few replicasAdd preStop.sleep and use maxUnavailable: 0
Exit code 137 after each deletionThe grace period is shorter than preStop plus graceful shutdownRaise terminationGracePeriodSeconds

11. When to Use Kubernetes and When to Avoid It

Good use casesPoor use cases
Several services owned by different teamsOne small app with a handful of users
Services that need multiple copies and automatic recoveryInternal tools where a short outage is fine
Frequent releases that need rolling updates and easy rollbackA team with no one to run or pay for a cluster
Load that changes during the dayWorkloads already served well by a simpler platform as a service (PaaS)

If you only need to run a container somewhere, a managed container service or plain Docker Compose is far simpler. Choose Kubernetes when its automation saves more work than it costs.

12. Best Practices

  • Pin image tags. Deploy catalog-service:1.0.0, never :latest, so every node runs the same build and rollbacks are predictable.
  • Keep liveness checks internal. Let liveness report only the app's own state. Put dependency checks, if any, in readiness.
  • Add a preStop pause and size the grace period to match. The grace period must cover the pause plus the graceful shutdown time, with a little extra.
  • Use a startup probe for the JVM's first start. This lets you keep liveness strict without killing slow-starting Pods.
  • Set memory requests and limits, and size the heap as a percentage. Use -XX:MaxRAMPercentage rather than a fixed -Xmx, so the heap follows the limit.
  • Keep secrets out of Git and the image. Create Secrets at deploy time, mount them as files, and read them with configtree:.
  • Roll out with maxUnavailable: 0. Old Pods stop only after new ones are ready, so capacity never drops.
  • Treat the YAML files as the source of truth. Change files in Git and run kubectl apply, rather than editing objects in the cluster by hand.

13. Summary

To deploy a Spring Boot application on Kubernetes, build a versioned image and make it available to the cluster, run it with a Deployment behind a Service, pass settings through a ConfigMap and credentials through a Secret mounted as a configuration tree, and point the startup, liveness and readiness probes at Spring Boot's Actuator health groups. Add a preStop pause and a grace period that cover Spring Boot's graceful shutdown, release with a rolling update that never drops below full capacity, and use kubectl rollout undo when you need to go back. The main recommendation: get the probes and the shutdown sequence right before anything else, because they are what turn "it runs on Kubernetes" into "it updates without anyone noticing".