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.
Contents
Chart Structure
A chart is a folder with a fixed layout. helm create
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
# Create scaffold with helm create
helm create payment-service
Chart.yaml
Chart.yamlversionappVersion
# 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
# 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 }}include_helpers.tplnindentconfig
{{/* 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:
{{/* 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
_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:
{{/* 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
# 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 installupgrade
# 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.migrations: the Flyway CLI plus this service's SQL scripts
FROM flyway/flyway:11
COPY src/main/resources/db/migration /flyway/sql
{{/* 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
hook runs. That is why the database Secret here is created outside the chart. Alternatively, make the Secret a hook too, with a lowerpre-install , so it is created first.hook-weight - 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
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 }}helmfile.yaml.gotmpl
# 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 repositorieschart: repo-name/chart-nameversion
# 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