Helm Charts for Spring Boot: Templating, Values & Rollbacks

Helm is the package manager for Kubernetes. Instead of maintaining separate YAML files per environment, you write templates with placeholder values and override them per deployment. A Helm release tracks every install and upgrade, making rollbacks a one-command operation. This article walks through creating a production-quality Helm chart for a Spring Boot service, customising it per environment, and managing the release lifecycle.

Share

Contents

Chart Structure

A chart is a folder with a fixed layout. helm create generates one with working templates for a Deployment, Service, Ingress, autoscaler and service account, which you then adapt. This article's chart looks like this:

Text
payment-service/            ← chart name
├── Chart.yaml              ← chart metadata (name, version, appVersion)
├── values.yaml             ← default values
├── values-staging.yaml     ← staging overrides
├── values-production.yaml  ← production overrides
└── templates/
    ├── _helpers.tpl        ← reusable named templates
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── configmap.yaml
    ├── hpa.yaml
    ├── serviceaccount.yaml
    ├── db-migrate-job.yaml ← database migration, run by a hook
    └── NOTES.txt           ← printed after install
Shell
# Create scaffold with helm create
helm create payment-service

Chart.yaml

Chart.yaml describes the chart itself. It has two versions that are easy to mix up: version is the version of the chart, raised whenever the templates change; appVersion is the version of the application it deploys, used here as the default image tag.

YAML
# Chart.yaml
apiVersion: v2
name: payment-service
description: Spring Boot payment microservice
type: application
version: 0.1.0          # chart version — increment on chart changes
appVersion: "1.0.0"     # application version — the Docker image tag
maintainers:
  - name: platform-team
    email: platform@example.com

values.yaml: Defaults

values.yaml holds the default for every setting the templates use. Each environment then overrides only what differs. The resource settings follow the advice in Deploying Spring Boot on Kubernetes: memory request equal to the limit, and no CPU limit.

YAML
# values.yaml
replicaCount: 2

image:
  repository: myorg/payment-service
  tag: ""                          # defaults to Chart.appVersion if empty
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

ingress:
  enabled: false
  className: nginx
  host: payment.example.com
  tls: true

resources:
  requests:
    cpu: 250m
    memory: 1Gi
  limits:
    memory: 1Gi

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

springProfiles: "production"

database:
  url: "jdbc:postgresql://postgres.payments.svc:5432/payments"

config:                 # becomes the ConfigMap shown below
  logLevel: INFO
  maxRetryAttempts: 3

podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}

Deployment Template

Templates are Kubernetes manifests with placeholders. {{ .Values.x }} inserts a value, include inserts a named template from _helpers.tpl, and nindent indents the result so the YAML stays valid. First, the ConfigMap that turns the config values into environment variables Spring Boot understands:

YAML
{{/* templates/configmap.yaml */}}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "payment-service.fullname" . }}-config
  labels:
    {{- include "payment-service.labels" . | nindent 4 }}
data:
  LOGGING_LEVEL_ROOT: {{ .Values.config.logLevel | quote }}
  APP_RETRY_MAX_ATTEMPTS: {{ .Values.config.maxRetryAttempts | quote }}

Then the Deployment:

YAML
{{/* templates/deployment.yaml */}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "payment-service.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "payment-service.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "payment-service.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "payment-service.selectorLabels" . | nindent 8 }}
      annotations:
        # Force pod restart when ConfigMap changes (checksum annotation)
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: {{ .Values.springProfiles | quote }}
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=75.0"
          envFrom:
            - configMapRef:
                name: {{ include "payment-service.fullname" . }}-config
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 15
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}

The checksum/config annotation solves a common surprise: changing a ConfigMap does not restart the pods that use it. The annotation holds a hash of the rendered ConfigMap, so any change to it changes the pod template, and Kubernetes rolls out new pods.

_helpers.tpl: Named Templates

Files starting with an underscore are not rendered as manifests. They hold named templates that the other files reuse, so names and labels are defined once:

YAML
{{/* templates/_helpers.tpl */}}

{{/* Full name: release-name + chart-name, capped at 63 chars */}}
{{- define "payment-service.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/* Common labels */}}
{{- define "payment-service.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{ include "payment-service.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/* Selector labels */}}
{{- define "payment-service.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Environment-Specific Overrides

An environment's values file contains only what differs from values.yaml. Helm merges the two, key by key:

YAML
# values-production.yaml — overrides on top of values.yaml defaults
image:
  tag: "1.2.3"   # pin exact version in production

ingress:
  enabled: true
  host: api.example.com

autoscaling:
  enabled: true          # the autoscaler sets the replica count, so replicaCount is ignored
  minReplicas: 3
  maxReplicas: 20

resources:
  requests:
    cpu: 500m
    memory: 2Gi
  limits:
    memory: 2Gi

config:
  logLevel: WARN

Install, Upgrade & Rollback

Every install and upgrade creates a new numbered revision of the release, and Helm keeps the previous ones, so a rollback is a single command:

Shell
# Lint — validate templates before deploying
helm lint ./payment-service -f payment-service/values-production.yaml

# Dry-run — render templates without applying
helm install payment-service ./payment-service \
  --namespace payments \
  --values payment-service/values-production.yaml \
  --dry-run --debug

# Install
helm install payment-service ./payment-service \
  --namespace payments --create-namespace \
  --values payment-service/values-production.yaml

# Upgrade — deploy new image version
helm upgrade payment-service ./payment-service \
  --namespace payments \
  --values payment-service/values-production.yaml \
  --set image.tag=1.3.0 \
  --atomic \
  --timeout 5m
# --atomic waits until the new pods are ready, and rolls back by itself
# if they are not ready within --timeout

# View release history
helm history payment-service -n payments

# Rollback to previous revision
helm rollback payment-service -n payments

# Rollback to specific revision
helm rollback payment-service 2 -n payments

# Uninstall
helm uninstall payment-service -n payments

Helm Hooks

A hook is a resource Helm creates at a fixed point in a release instead of with the rest, for example a Job that migrates the database before new pods start. The example runs Flyway from an image that contains only the migration scripts:

Dockerfile
# Dockerfile.migrations: the Flyway CLI plus this service's SQL scripts
FROM flyway/flyway:11
COPY src/main/resources/db/migration /flyway/sql
YAML
{{/* templates/db-migrate-job.yaml */}}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "payment-service.fullname" . }}-db-migrate
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 1
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "myorg/payment-service-migrations:{{ .Values.image.tag | default .Chart.AppVersion }}"
          args: ["migrate"]
          env:
            - name: FLYWAY_URL
              value: {{ .Values.database.url | quote }}
            - name: FLYWAY_USER
              valueFrom:
                secretKeyRef: { name: payment-service-db, key: username }
            - name: FLYWAY_PASSWORD
              valueFrom:
                secretKeyRef: { name: payment-service-db, key: password }

If the Job fails, Helm stops the upgrade and the running version stays in place. Two things to know:

  • Hooks run before the chart's own resources exist. On a first install, a Secret or ConfigMap that the chart creates is not there yet when a pre-install hook runs. That is why the database Secret here is created outside the chart. Alternatively, make the Secret a hook too, with a lower hook-weight, so it is created first.
  • Old and new pods share the database during a rollout. The migration runs before the new pods start, while the old ones still serve traffic, so each migration must keep working with the previous version of the code.

If you do not need a separate step, Spring Boot runs Flyway itself at startup when flyway-core is on the class path. That is simpler, but every pod then tries to migrate as it starts.

Helmfile: Multi-Service Deployments

Helmfile declaratively manages multiple Helm releases: useful for deploying a full microservices stack with one command. The file below uses a template expression, {{ .Environment.Name }}, so it must be named helmfile.yaml.gotmpl; since Helmfile 1.0, only files with that ending are processed as templates.

YAML
# helmfile.yaml.gotmpl
releases:
  - name: payment-service
    namespace: payments
    chart: ./charts/payment-service
    values:
      - ./values/payment-service/values-{{ .Environment.Name }}.yaml

  - name: order-service
    namespace: orders
    chart: ./charts/order-service
    values:
      - ./values/order-service/values-{{ .Environment.Name }}.yaml

environments:
  staging: {}
  production: {}

Charts from a repository are added the same way, with a repositories list and chart: repo-name/chart-name. Pin each one with version, so a new chart release does not reach production unnoticed.

Shell
# Deploy all services to staging
helmfile -e staging apply

# Diff before applying to production
helmfile -e production diff

# Apply to production
helmfile -e production apply