Spring Boot 3 + Docker + Kubernetes: Complete Production Deployment Guide (2026)
Introduction
Containerising and deploying a Spring Boot application to Kubernetes has become the de-facto standard for production Java services. Yet the path from mvn package to a running, auto-scaling Kubernetes pod trips up even experienced engineers — multi-stage Dockerfiles, liveness vs. readiness probes, ConfigMaps vs. Secrets, Horizontal Pod Autoscalers, Helm charts — each piece matters, and a misconfiguration in any layer cascades into hard-to-diagnose failures.
This guide walks you through the complete pipeline: building a Spring Boot 3.4 REST API, packaging it into a lean Docker image with a multi-stage build, writing production-quality Kubernetes manifests, and deploying with Helm. By the end you will have a running application in Kubernetes with health probes, external configuration, secrets management, and auto-scaling configured correctly.
What we build: A simple Product Catalogue API backed by PostgreSQL — representative enough to cover the patterns that matter in real projects.
Prerequisites
Before starting, make sure you have the following installed:
- Java 21 (JDK), Maven 3.9+
- Docker Desktop (or Colima on macOS)
- A local Kubernetes cluster: minikube (recommended for this guide)
kubectl1.29+- Helm 3.14+
- (Optional) A Docker Hub account to push images for remote clusters
Verify versions:
java -version # openjdk 21+
docker --version # Docker 27+
minikube version # v1.34+
kubectl version --client
helm version
Project Setup
We'll use the following Spring Boot dependencies:
- Spring Web — REST API layer
- Spring Data JPA — persistence
- PostgreSQL Driver — database connectivity
- Spring Boot Actuator — health probes (critical for Kubernetes)
- Lombok — boilerplate reduction
- Validation — input validation
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
</parent>
<groupId>com.vimleshpandey</groupId>
<artifactId>demo</artifactId>
<version>1.0.0</version>
<name>spring-k8s-demo</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>
</project>
The setting produces a layered JAR where library classes are extracted into a separate Docker layer, cutting re-build times dramatically — only your changed application classes need re-uploading on subsequent deploys.
Application Code
Main Application Class
package com.vimleshpandey.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
Product Entity
package com.vimleshpandey.demo.model;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
@Entity
@Table(name = "products")
@Getter @Setter @NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
@Size(max = 200)
private String name;
@NotNull
@Positive
private Double price;
@Size(max = 1000)
private String description;
}
Repository and Service
package com.vimleshpandey.demo.repository;
import com.vimleshpandey.demo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {}
package com.vimleshpandey.demo.service;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
public List<Product> findAll() { return repository.findAll(); }
public Product findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found: " + id));
}
public Product create(Product product) { return repository.save(product); }
public void delete(Long id) { repository.deleteById(id); }
}
REST Controller
package com.vimleshpandey.demo.controller;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService service;
@GetMapping
public List<Product> list() { return service.findAll(); }
@GetMapping("/{id}")
public Product get(@PathVariable Long id) { return service.findById(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product create(@Valid @RequestBody Product product) {
return service.create(product);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) { service.delete(id); }
}
application.yml
spring:
application:
name: spring-k8s-demo
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/productsdb}
username: ${DATABASE_USER:postgres}
password: ${DATABASE_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: update
show-sql: false
server:
port: 8080
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
probes:
enabled: true
show-details: always
health:
livenessState:
enabled: true
readinessState:
enabled: true
The critical settings are management.endpoint.health.probes.enabled: true and the liveness/readiness state beans. These expose /actuator/health/liveness and /actuator/health/readiness — the exact endpoints Kubernetes health probes will poll.
Dockerizing with a Multi-Stage Build
The most common Docker mistake for Spring Boot is a single-stage build that copies the fat JAR, producing images of 350–500 MB. A multi-stage build with the layered JAR produces images around 90 MB and re-builds in seconds.
Dockerfile
# Stage 1: Extract layered JAR
FROM eclipse-temurin:21-jre-jammy AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
# Stage 2: Lean runtime image
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
RUN addgroup --system spring && adduser --system --ingroup spring spring
USER spring:spring
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Key decisions in this Dockerfile:
eclipse-temurin:21-jre-jammy— JRE-only (not JDK) keeps the image smaller. Jammy (Ubuntu 22.04 LTS) is a stable, well-patched base.- Non-root user — required by most enterprise Kubernetes security policies (
PodSecurityStandards: restricted). Running as root inside a container is a significant security risk. - Layer ordering — dependencies before application code so the Docker layer cache hits on the heavy library layers, meaning only your changed class files re-upload on the next build.
Build and test locally before going to Kubernetes:
mvn clean package -DskipTests
docker build -t spring-k8s-demo:1.0.0 .
docker run -p 8080:8080 \
-e DATABASE_URL=jdbc:postgresql://host.docker.internal:5432/productsdb \
spring-k8s-demo:1.0.0
Kubernetes Manifests
Start minikube and create a dedicated namespace:
minikube start --cpus 4 --memory 8192
kubectl create namespace demo
ConfigMap — externalised non-secret configuration
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: spring-k8s-config
namespace: demo
data:
DATABASE_URL: "jdbc:postgresql://postgres-service:5432/productsdb"
Secret — database credentials
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: spring-k8s-secret
namespace: demo
type: Opaque
stringData:
DATABASE_USER: "appuser"
DATABASE_PASSWORD: "supersecret"
Production note: Never commit plaintext Secrets to Git. In production, use Sealed Secrets (Bitnami), External Secrets Operator (AWS Secrets Manager / HashiCorp Vault), or the Kubernetes CSI Secret Store driver.
Deployment
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-k8s-demo
namespace: demo
labels:
app: spring-k8s-demo
spec:
replicas: 2
selector:
matchLabels:
app: spring-k8s-demo
template:
metadata:
labels:
app: spring-k8s-demo
spec:
containers:
- name: spring-k8s-demo
image: spring-k8s-demo:1.0.0
imagePullPolicy: Never
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: spring-k8s-config
- secretRef:
name: spring-k8s-secret
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "512Mi"
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
failureThreshold: 30
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 60
Why three probes?
- startupProbe — gives the JVM up to 150 seconds (30 × 5s) to reach a live state. Once it passes, the
livenessProbetakes over. This prevents liveness from killing slow-starting pods prematurely. - livenessProbe — if this fails, Kubernetes restarts the container. Fires every 10 seconds once the app is running.
- readinessProbe — if this fails, the pod is silently removed from the Service's endpoint list. No traffic is sent; no restart occurs. Perfect for temporary degradation (e.g., a DB is momentarily unreachable).
The preStop sleep is equally important: it buys time for the load balancer to drain connections before the JVM begins its shutdown sequence. Without it, in-flight requests die during rolling updates.
Service
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
name: spring-k8s-demo-svc
namespace: demo
spec:
selector:
app: spring-k8s-demo
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
Ingress
Enable the NGINX Ingress controller on minikube first:
minikube addons enable ingress
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: spring-k8s-demo-ingress
namespace: demo
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: spring-demo.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: spring-k8s-demo-svc
port:
number: 80
Add spring-demo.local to /etc/hosts pointing to minikube ip output.
Horizontal Pod Autoscaler
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: spring-k8s-demo-hpa
namespace: demo
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: spring-k8s-demo
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
Enable Metrics Server: minikube addons enable metrics-server. The HPA scales pods out when average CPU utilisation across the deployment exceeds 60%.
Running the Application
Deploy with kubectl
# Load image into minikube's local Docker daemon (skip if using Docker Hub)
minikube image load spring-k8s-demo:1.0.0
# Apply in dependency order
kubectl apply -f k8s/configmap.yaml
kubectl apply -f k8s/secret.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
kubectl apply -f k8s/hpa.yaml
# Watch rollout progress
kubectl rollout status deployment/spring-k8s-demo -n demo
Verify and test
kubectl get pods -n demo
kubectl get svc -n demo
kubectl get ingress -n demo
# Stream logs
kubectl logs -f deployment/spring-k8s-demo -n demo
# Expose via minikube tunnel (separate terminal)
minikube tunnel
# Hit the API
curl http://spring-demo.local/api/products
curl -X POST http://spring-demo.local/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Widget","price":9.99,"description":"A fine widget"}'
# Check health probes directly
curl http://spring-demo.local/actuator/health/liveness
curl http://spring-demo.local/actuator/health/readiness
Packaging with Helm
For real deployments, Helm beats raw manifests: it templates values, supports versioned upgrades and instant rollbacks, and integrates with GitOps tools like ArgoCD and Flux.
helm create spring-k8s-chart
Customise values.yaml:
image:
repository: spring-k8s-demo
tag: "1.0.0"
pullPolicy: Never
replicaCount: 2
env:
DATABASE_URL: "jdbc:postgresql://postgres-service:5432/productsdb"
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1"
memory: "512Mi"
Install and manage:
# Install
helm install spring-k8s-demo ./spring-k8s-chart \
--namespace demo
# Upgrade to a new image version (--atomic auto-rolls-back on failure)
helm upgrade spring-k8s-demo ./spring-k8s-chart \
--namespace demo \
--set image.tag=1.1.0 \
--atomic
# Instant rollback if something goes wrong
helm rollback spring-k8s-demo 1
# Show history
helm history spring-k8s-demo -n demo
--atomic is essential in CI/CD: it fails the pipeline and rolls back automatically if pods don't reach Running within the timeout, rather than leaving the cluster in a half-upgraded state.
Testing
package com.vimleshpandey.demo;
import com.vimleshpandey.demo.controller.ProductController;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.service.ProductService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(controllers = ProductController.class)
class ProductControllerTest {
@Autowired MockMvc mvc;
@MockBean ProductService service;
@Test
void list_returnsProducts() throws Exception {
Product p = new Product();
p.setId(1L); p.setName("Widget"); p.setPrice(9.99);
when(service.findAll()).thenReturn(List.of(p));
mvc.perform(get("/api/products").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Widget"))
.andExpect(jsonPath("$[0].price").value(9.99));
}
@Test
void create_returnsCreated() throws Exception {
Product p = new Product();
p.setId(2L); p.setName("Gadget"); p.setPrice(19.99);
when(service.create(org.mockito.ArgumentMatchers.any())).thenReturn(p);
mvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Gadget\",\"price\":19.99}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(2));
}
}
Run tests: mvn test
Common Pitfalls and Tips
1. ImagePullBackOff in minikube
You built the image on your host but minikube runs a separate Docker daemon inside its VM. Load the image explicitly: minikube image load spring-k8s-demo:1.0.0. Set imagePullPolicy: Never for locally loaded images.
2. OOMKilled pods
Spring Boot 3.x on JDK 21 respects cgroup memory limits via -XX:+UseContainerSupport (enabled by default). Still, set -XX:MaxRAMPercentage=75.0 to leave headroom for non-heap (Metaspace, thread stacks, native memory). Add to ENTRYPOINT: ["java", "-XX:MaxRAMPercentage=75.0", "org.springframework.boot.loader.launch.JarLauncher"].
3. Liveness probe killing healthy pods
If your app takes more than initialDelaySeconds to start, liveness fails and Kubernetes enters a restart loop. Use startupProbe (as shown above) instead of a large initialDelaySeconds — it is cleaner and correct.
4. Database not ready on pod start
Use an init container to wait for PostgreSQL before the app starts:
initContainers:
- name: wait-for-db
image: busybox
command: ['sh', '-c', 'until nc -z postgres-service 5432; do sleep 2; done']
5. CPU throttling
CPU limits cause throttling even when the node has spare capacity, which is silently devastating for latency-sensitive apps. Profile with kubectl top pod -n demo under realistic load before setting limits. Many teams omit CPU limits entirely and rely on CPU requests for scheduling.
6. Secrets in plain text
The k8s/secret.yaml shown above is for local development only. In a real cluster, integrate External Secrets Operator with AWS Secrets Manager or HashiCorp Vault, and never store credential values in any file committed to source control.
Conclusion
You now have a production-grade Spring Boot 3.4 application running in Kubernetes with:
- A lean multi-stage Docker image (~90 MB vs ~450 MB naive)
- Startup, liveness, and readiness probes backed by Spring Actuator
- External configuration via ConfigMap and Secret
- Graceful shutdown with preStop hook for zero-downtime rolling updates
- Horizontal Pod Autoscaling on CPU utilisation
- A Helm chart for versioned, rollback-capable deployments
From here, the natural next steps are wiring this into a GitHub Actions CI/CD pipeline (trigger a Helm upgrade on every push to main), adding distributed tracing with OpenTelemetry and Jaeger, and hardening secrets with External Secrets Operator. The downloadable project files below include all manifests and source code ready to run.