Docker & Kubernetes7 min read·By Liyabona Saki··

Deploy Spring Boot to Kubernetes with a Helm Chart

Package a Spring Boot service as a reusable Helm chart with values for env, replicas, probes and HPA.

The Failure of Standard Manifests vs. Reusable Helm Logic

Moving from raw Kubernetes manifests to Helm isn't just about template injection; it’s about decoupling the infrastructure lifecycle from the application code. If you are coming from a world of static deployment.yaml and service.yaml files tucked into a /k8s folder in your Spring Boot repo, you are likely suffering from "copy-paste drift." When you have ten microservices, and you decide to change the terminationGracePeriodSeconds from 30 to 60 because your HikariCP connection pool needs more time to drain, you have to manually edit ten files.

Helm allows you to treat your deployment logic as a versioned artifact. For Spring Boot specifically, this is critical because JVM-based workloads have unique requirements around memory limits, GC overhead, and liveness probes that differ significantly from lightweight Go or Node binaries. We aren't just templating strings; we are codifying the operational guardrails of the JVM on Linux cgroups.

Deployment Strategy: What Changes vs. Standard EC2/Systemd

If you are migrating from a traditional VM-based deployment (AWS EC2/systemd), the biggest shift is the "Liveness vs. Readiness" dichotomy. In a VM, if the process is up, the load balancer sends traffic. In Kubernetes, a Spring Boot process starting up can be "running" but not "ready."

Spring Boot’s Actuator is the key here. Unlike a simple /health check in a Python script, Spring Boot's startup includes context initialization, bean wiring, and database migration (if you're using Flyway/Liquibase). If you signal readiness too early, the first 1,000 requests hit a 503 Service Unavailable because the Hibernate session factory hasn't finished warming up.

```text
TRAFFIC FLOW TOPOLOGY:

Internet -> Ingress Controller (Nginx/ALB) -> ClusterIP Service -> Pod (Spring Boot) | |-- /actuator/health/liveness (Exit 1 on Panic) |-- /actuator/health/readiness (Remove from LB) |-- /actuator/prometheus (Scraping) ```

In a Helm chart, we define these probes using values.yaml to ensure they are tuned to the specific startup time of the app. A common mistake is setting initialDelaySeconds too low, causing Kubelet to kill the pod while it's still trying to start the JVM, leading to a permanent CrashLoopBackOff.

Resource Management: What Changes vs. Python/Go

Spring Boot is a memory-hungry beast. In Go, you might get away with 128Mi of RAM. In Spring Boot, you need to account for the Heap, the Metaspace, and the Stack.

A hard-won lesson from production: always set XX:MaxRAMPercentage=75.0. If you set a hard heap size like -Xmx2g but your Kubernetes limit is also 2Gi, the JVM will eventually hit the limit, but the overhead of the OS and the JVM itself will trigger an OOMKill before the GC can reclaim space. By using percentages, the JVM remains aware of the cgroup limits.

In your Helm values.yaml, your resource constraints should look like this:

```yaml
# values.yaml
image:
  repository: registry.digitalocean.com/my-org/my-spring-app
  tag: "v1.2.3"

resources: limits: cpu: 1000m # Spring Boot needs CPU to start fast; don't throttle it memory: 2Gi requests: cpu: 500m memory: 1.5Gi

env: - name: JAVA_OPTS value: "-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom" - name: SPRING_PROFILES_ACTIVE value: "prod" ```

The -Djava.security.egd flag is a classic "production-only" fix. In headless Linux environments, /dev/random can block waiting for entropy, which can stall Spring Boot's SecureRandom initialization for up to 2 minutes. Using /dev/urandom ensures the app starts in seconds rather than minutes.

Networking and Service Discovery: What Changes vs. Localhost/Docker Compose

When developing locally with Docker Compose, you might use links or just point to localhost. In a Helm-deployed Kubernetes environment, you rely on the CoreDNS service name.

The Helm chart must dynamically generate the Service name. This is usually handled in _helpers.tpl. The mistake most engineers make is hardcoding the service name in the Spring application.yaml. Instead, inject the service URL via an environment variable in the Helm template.

yaml
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "mychart.fullname" . }}
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - name: http
              containerPort: 8080
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            initialDelaySeconds: 60 # Don't check until JVM is likely up
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
          env:
            {{- toYaml .Values.env | nindent 12 }}
            - name: DB_URL
              value: "jdbc:postgresql://{{ .Values.db.host }}:5432/{{ .Values.db.name }}"

By using {{- toYaml .Values.env | nindent 12 }}, we allow the values.yaml to remain the single source of truth for environment-specific secrets and config.

Horizontal Pod Autoscaling (HPA): What Changes vs. Manual Scaling

On a single server, you scale vertically. In Kubernetes, we use the Horizontal Pod Autoscaler. For Spring Boot, standard CPU-based scaling can be misleading. A Java app might sit at 20% CPU while its DB connection pool is saturated, or it might spike to 90% CPU during a brief GC pause which doesn't reflect actual load.

When configuring the HPA in your Helm chart, you should target both CPU and Memory.

yaml
# templates/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "mychart.fullname" . }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "mychart.fullname" . }}
  minReplicas: {{ .Values.hpa.minReplicas }}
  maxReplicas: {{ .Values.hpa.maxReplicas }}
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80

In a high-throughput environment (e.g., 5,000 requests per second), we observed that Spring Boot's p99 latency dropped from 480ms to 90ms after we implemented custom metrics for HPA based on the http_server_requests_seconds_count metric from Micrometer, rather than just raw CPU utilization. CPU can be a lagging indicator for JVM apps.

Configuration Injection: What Changes vs. Spring Cloud Config

If you are used to Spring Cloud Config Server, the "Kubernetes way" is to use ConfigMaps and Secrets. Helm shines here because it can trigger a rolling update of your pods whenever a ConfigMap changes.

Kubernetes, by default, does not restart a pod when a ConfigMap is updated. To fix this, we use a checksum annotation in the deployment.yaml template:

yaml
kind: Deployment
spec:
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}

When you run helm upgrade, Helm calculates the hash of your configmap.yaml. If a property like logging.level.org.springframework changes, the hash changes. Kubernetes sees the new annotation in the Deployment spec and triggers a rolling restart of the pods. This eliminates the need for /actuator/refresh or custom setup logic.

Logging and Observability: What Changes vs. File Appenders

On a VM, you might write logs to /var/log/myapp/app.log and rotate them with logrotate. In a Helm-managed Spring Boot app, you must log to STDOUT.

Your Spring logback-spring.xml should use the ConsoleAppender with a JSON encoder. This allows tools like Fluentd or Promtail to scrape the logs and ship them to an ELK or Loki stack without needing to manage file volumes. Use the Helm values.yaml to toggle the log format:

yaml
# values.yaml
logging:
  format: json

And in your configmap.yaml template:

yaml
data:
  APPLICATION_JSON: |
    {
      "spring": {
        "main": { "banner-mode": "off" }
      },
      "logging": {
        "level": { "root": "INFO" }
      }
    }

The Criticality of Graceful Shutdown

Spring Boot 2.3+ introduced native support for graceful shutdown. When a pod is being terminated (e.g., during a rolling update or a scale-down event), Kubernetes sends a SIGTERM. Without graceful shutdown, the JVM kills all threads immediately, potentially cutting off inflight database transactions.

In your Helm chart's environment variables, ensure server.shutdown=graceful is set. Additionally, set the lifecycle preStop hook in deployment.yaml. This is a "safety first" mechanism that ensures the load balancer stops sending traffic to the pod *before* the JVM begins its internal shutdown sequence.

yaml
lifecycle:
  preStop:
    exec:
      command: ["sh", "-c", "sleep 10"]

This 10-second sleep is a production standard. It gives the Ingress controller enough time to update its endpoint list and stop routing new requests to the terminating pod. Without this, you will see a spike of 502 Bad Gateway errors during every deployment.

Validating the Helm Transformation

The shift from manual manifests to a Helm chart for Spring Boot is the shift from "running a jar" to "managing a distributed service." By codifying memory percentages, readiness probe delays, and checksum-based restarts into a versioned Helm chart, you treat your deployment logic with the same rigor as your Java code.

The most important takeaway: the JVM is not "container aware" by default in older versions, and even in modern versions, it needs the resource limit hints provided by Helm. Your p99 latencies and your cluster's stability depend less on your Java code and more on how well your Helm templates handle the transition from the "warm" world of a VM to the "volatile" world of Kubernetes pods. Use the preStop hook and MaxRAMPercentage or prepare for inconsistent performance.

Trade-offs: Helm vs raw manifests vs Kustomize

The three ways to package a Spring Boot service for Kubernetes each optimize for a different pain point. Raw YAML manifests are the simplest to read and grep, and are ideal for a team running one or two services in one environment. They break down the moment you need to templatise per-environment values. Kustomize stays declarative and pure YAML, and its overlay model is elegant when the differences between environments are small — this is Kubernetes-native and needs no extra tool. Helm wins when you are packaging a service for reuse (internal platform, open-source distribution) or when the parameterisation is genuinely complex — but you pay for it with a templating language most engineers actively dislike and a release model that adds state to your cluster. A common mature setup is Kustomize for in-house services and Helm only for third-party charts.

Go deeper

Further reading

Source Code

Get the full project on GitHub

View repo →
#Kubernetes#Helm#Spring Boot#DevOps

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Docker & Kubernetes

Dockerizing a Spring Boot Application: The Right Way

Build small, fast and secure Docker images for Spring Boot using multi-stage builds, layered jars and JVM container tuning.

Related tutorials