How to Deploy a Spring Boot Application on Kubernetes
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
Follow a request through Figure 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. - 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-servicecontainer. - The Deployment makes sure two Pods exist at all times. It also controls how they are replaced during an update.
- Each Pod reads normal settings as environment variables from a ConfigMap, and the database credentials as files from a Secret.
- 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
| Tool | Purpose |
|---|---|
| JDK 21+ and Maven wrapper | Builds the jar |
| Docker | Builds the image |
| kind | Runs a Kubernetes cluster inside Docker on your machine |
| kubectl | The command-line client for Kubernetes |
Create a cluster named shop:
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.
- 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 usespring-boot-starter-web. - Layer extraction: the Dockerfile's
-Djarmode=toolsmode replaces the oldlayertoolsmode, which Spring Boot 4.1 removed. - preStop sleep: the built-in
sleepaction 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:
| Probe | Question it asks | What happens on failure | Spring Boot endpoint used here |
|---|---|---|---|
| Startup | Has the app finished starting? | Container is restarted; the other probes wait until it passes | /actuator/health/liveness |
| Liveness | Is the app still working, or stuck? | Container is restarted | /actuator/health/liveness |
| Readiness | Can 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:
CORRECTorBROKEN. - ReadinessState:
ACCEPTING_TRAFFICorREFUSING_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:
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());
}
}
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:
- The control plane removes the Pod from the Service's endpoint list. Load balancers and other nodes take a few seconds to notice.
- 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:
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:
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:
catalog-service/
├── Dockerfile
├── pom.xml
├── src/main/...
└── k8s/
├── postgres.yaml
├── catalog-config.yaml
├── catalog-deployment.yaml
└── catalog-service.yaml
7.1 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.
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:
volumeClaimTemplatesrequests a 1 GB PersistentVolumeClaim, a request for disk space that outlives the Pod. - Service:
clusterIP: Nonemakes the Service headless, so the namepostgresresolves straight to the database Pod.
7.3 ConfigMap
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
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: 1andmaxUnavailable: 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: IfNotPresentwith 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
itemslist mounts the Secret keysusernameandpasswordas files namedspring.datasource.usernameandspring.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, useexec: command: ["sh", "-c", "sleep 10"]instead. That works with this image because its Temurin base includes a shell.
7.5 Service
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
# 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
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.
kubectl get pods
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:
kubectl port-forward service/catalog-service 8080:80
Forwarding from 127.0.0.1:8080 -> 8080
In a second 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
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:
- Set
<version>1.0.1</version>inpom.xml. - Rebuild, and load the new image into kind.
- Change the image tag in
k8s/catalog-deployment.yamltojavabytechie/catalog-service:1.0.1. - Apply the files again.
Editing the YAML file, rather than running kubectl set image, keeps the files in Git matching what actually runs.
./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.
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'
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:
kubectl rollout history deployment/catalog-service
kubectl rollout undo deployment/catalog-service
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
| Setting | Where | Default | Value in this example |
|---|---|---|---|
replicas | Deployment | 1 | 2 |
maxSurge / maxUnavailable | Deployment strategy | 25% / 25% | 1 / 0 |
terminationGracePeriodSeconds | Pod spec | 30 | 45 |
periodSeconds / timeoutSeconds / failureThreshold | Each probe | 10 / 1 / 3 | Set per probe |
preStop.sleep.seconds | Container lifecycle | none | 10 |
server.shutdown | Spring property | graceful (since 3.4) | default |
spring.lifecycle.timeout-per-shutdown-phase | Spring property | 30s | default |
management.endpoint.health.probes.enabled | Spring property | on automatically on Kubernetes | true |
management.endpoint.health.probes.add-additional-paths | Spring property | false | not used; when true, also serves /livez and /readyz on the main port |
spring.main.cloud-platform | Spring property | auto-detected | not set |
10. Common Pitfalls and How to Fix Them
| Symptom | Likely cause | Fix |
|---|---|---|
ErrImagePull or ImagePullBackOff | The image isn't in the kind node, or the tag is :latest, which makes Kubernetes pull it from a registry | Run kind load docker-image ..., use a fixed tag and imagePullPolicy: IfNotPresent |
CrashLoopBackOff | The app exits at startup, often because the database is unreachable or a property is wrong | Check kubectl logs <pod> --previous and confirm the ConfigMap URL and Secret |
OOMKilled, or exit code 137 under load | The heap plus native memory exceeds the memory limit | Keep MaxRAMPercentage around 75 and raise the limit if needed |
Pod Running but READY 0/1 | Readiness fails: wrong path or port, or the endpoint isn't exposed | Run kubectl describe pod <pod>; check that management.endpoints.web.exposure.include contains health |
| All Pods restart when the database blips | A database check was added to the liveness group | Keep liveness to livenessState only |
| Failed requests during every rollout | No preStop pause, or maxUnavailable greater than 0 with few replicas | Add preStop.sleep and use maxUnavailable: 0 |
| Exit code 137 after each deletion | The grace period is shorter than preStop plus graceful shutdown | Raise terminationGracePeriodSeconds |
11. When to Use Kubernetes and When to Avoid It
| Good use cases | Poor use cases |
|---|---|
| Several services owned by different teams | One small app with a handful of users |
| Services that need multiple copies and automatic recovery | Internal tools where a short outage is fine |
| Frequent releases that need rolling updates and easy rollback | A team with no one to run or pay for a cluster |
| Load that changes during the day | Workloads 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:MaxRAMPercentagerather 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".