Helm Chart Structure
1. Overview
Every EMS Spring Boot service ships its own Helm chart under src/main/helm/. The chart is per-service, not per-environment — values files (managed by ArgoCD, see ArgoCD Deployment Patterns) provide environment-specific overrides.
The chart produces a single Kubernetes Deployment + Service + optional Ingress + ConfigMap + Secret references. No CRDs, no operators, no chart dependencies. Keep it minimal.
This page covers the directory layout, the values schema, template conventions, and how the chart gets published.
See also: Jib Docker Build, ArgoCD Deployment Patterns.
2. Directory Layout
<service>/src/main/helm/
├── Chart.yaml # chart metadata
├── values.yaml # default values (dev-friendly)
└── templates/
├── deployment.yaml # the one Deployment
├── service.yaml # the one Service
├── ingress.yaml # optional, per values.ingress.enabled
├── configmap.yaml # non-sensitive config surfacing values.config.*
├── secret.yaml # optional — if not using existingSecret
├── _helpers.tpl # standard label / selector helpers
└── NOTES.txt # post-install user instructions (brief)
Reference: [admin-service/src/main/helm/](admin-service/src/main/helm/), [registration-portal/src/main/helm/](registration-portal/src/main/helm/).
3. Chart.yaml
apiVersion: v2
name: event-admin-portal
description: Helm chart for the EMS Admin Portal gateway.
type: application
version: 0.0.0
appVersion: 0.0.0
version and appVersion are overwritten at build time by the helm-maven-plugin using project.version. The committed 0.0.0 is a placeholder — never rely on it as the real version.
4. values.yaml Schema
The standard schema (inherited from the JHipster convention, extended for EMS):
config:
profiles: "dev" # comma-separated Spring profiles
existingsecret: "" # name of a pre-existing K8s secret; if set, chart does not create a Secret
db:
url: "" # no jdbc:/r2dbc: prefix
username: ""
password: ""
security:
jwt:
encryptionkey: ""
oauth2:
enabled: false
issuer: ""
clientid: ""
clientsecret: ""
mail:
host: ""
port: 25
from: ""
username: ""
password: ""
protocol: smtp
tls: false
properties.mail.smtp: {}
liquibase:
contexts: "prod"
services:
apikey: "" # admin-service X-API-KEY for portals
eventadminservice: "" # base URL (in-cluster http://prod-event-admin-service)
logging:
level:
ROOT: INFO
otel:
enabled: false
url: ""
hazelcast:
clusterName: ""
serviceName: "" # K8s Service name for peer discovery
image:
repository: docker.io/christhonie/event-admin-portal
pullPolicy: IfNotPresent
# tag defaults to appVersion
imagePullSecrets:
- name: christhonie-docker
ingress:
enabled: false
className: "nginx"
hosts: []
tls: []
resources:
requests:
cpu: 200m
memory: 512Mi
limits:
memory: 1Gi
replicaCount: 1
Keep the schema stable across services — operators should be able to copy a values file between services and mostly see the same keys.
5. Template Conventions
5.1. Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "chart.fullname" . }}
labels: {{- include "chart.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels: {{- include "chart.selectorLabels" . | nindent 6 }}
template:
metadata:
labels: {{- include "chart.selectorLabels" . | nindent 8 }}
spec:
imagePullSecrets: {{- toYaml .Values.imagePullSecrets | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 12506
protocol: TCP
env:
- name: SPRING_PROFILES_ACTIVE
value: {{ .Values.config.profiles | quote }}
{{- if .Values.config.existingsecret }}
- name: APPLICATION_ADMIN_SERVICE_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Values.config.existingsecret }}
key: apikey
{{- end }}
# ... other env mappings ...
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 30
livenessProbe:
httpGet:
path: /livez
port: http
initialDelaySeconds: 60
resources: {{- toYaml .Values.resources | nindent 12 }}
Key conventions:
-
Port name is
http, regardless of numeric port. -
Readiness probe on
/readyz, liveness probe on/livez— actuator health groups, not the full health endpoint. What each one evaluates is decided entirely by itsincludelist, and is not lightweight by default. See Health group membership. -
Env vars use Spring’s
SPRING_*binding convention.APPLICATION_*maps toapplication.*config tree.
5.1.1. Health group membership
A group’s members come from management.endpoint.health.group.<name>.include.
An empty include does not mean "the availability states". It means every registered contributor. Spring’s IncludeExcludeGroupMemberPredicate short-circuits on include.isEmpty(). Declaring a group by hand also suppresses Spring’s own availability-state-only readiness and liveness groups, which would otherwise supply these exact paths for free via management.endpoint.health.probes.add-additional-paths.
So a group declared with presentation and routing but no include puts db, hazelcast, diskSpace, ssl, clientConfigServer, discoveryComposite and refreshScope on the probe paths. Any degraded dependency then restarts the container — which repairs nothing, and removes serving capacity while that dependency is already struggling.
State membership explicitly. The test to apply per contributor is "would a restart fix this?"
group:
readiness:
show-details: never
additional-path: 'server:/readyz'
include: 'readinessState'
liveness:
show-details: never
additional-path: 'server:/livez'
include: 'livenessState'
- Liveness
-
Only conditions a restart repairs — deadlock, unrecoverable internal state. Nothing external belongs here: restarting a pod does not repair a database or an SMTP relay.
- Readiness
-
Conditions under which this pod should stop taking traffic but stay alive.
- Neither
-
Every other contributor. These belong on
/management/healthfor observation and alerting, and nowhere near pod lifecycle. Registering a new health indicator must not silently add it to a probe.
Whether db belongs in readiness
This is a judgement, and it turns on replica count rather than on preference.
Readiness controls Service endpoint membership, so its only real purpose is to shed traffic to a peer that can still serve. On a single-replica deployment there is no peer, and including db can only empty the Service entirely: the request that would have returned a 503 with a body gets a connection refusal instead, and /management/health stops being reachable through the ingress at the moment someone is trying to diagnose the outage. Most API operations fail during a database outage in any case, so the choice is how calls fail, not whether they do.
There is also an interaction with the startup probe. Where the startup probe targets /readyz as well, putting db in readiness means a database outage during a rollout stops new pods ever passing startup and the kubelet kills them — reintroducing restart-on-a-degraded-dependency by another route.
On a multi-replica deployment the calculation changes: one pod’s connection pool can be wedged while its peers are healthy, and shedding that pod is correct. Add db to the readiness include and move the startup probe to /livez in the same change.
5.2. Service
Standard ClusterIP service on port 80 → 12506. Name matches the release name so ArgoCD’s naming produces e.g. prod-event-admin-portal (Service) consumed by other in-cluster clients as http://prod-event-admin-portal.
5.3. Ingress
Optional per values.ingress.enabled. When on, defaults to className: nginx with TLS via cert-manager-annotated secrets. Example production values:
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: 10m
hosts:
- host: admin.event.idealogic.co.za
paths:
- path: /
pathType: Prefix
tls:
- secretName: admin-event-idealogic-co-za-tls
hosts:
- admin.event.idealogic.co.za
Multi-tenant portals host tenant-specific subdomains. The hosts list grows with tenant onboarding — managed by ArgoCD values, not by chart changes.
6. Secrets
Two modes:
-
Chart-managed —
values.config.existingsecretis empty; chart creates a Secret fromvalues.config.*fields. Fine for dev; never for prod (secrets in values are secrets in git). -
Pre-existing —
values.config.existingsecret: <name>; operator creates the Secret out-of-band (viakubectl, SealedSecrets, or External Secrets Operator), chart references keys by name. Always used in prod.
Prod secret creation is a runbook item — see ArgoCD Deployment Patterns.
6.1. Credentials must not reach the ConfigMap
A values.config.* subtree rendered into the ConfigMap with toYaml carries every key under it, credentials included — and a ConfigMap is readable by anything that can list the namespace, as well as being committed in the deployment manifest in clear text. Where such a block may hold a password, exclude that key by name from the dump (omit) and source it from the Secret instead.
The Secret is mounted as a directory and read by Spring Cloud Kubernetes, so the key name is the Spring property name — spring.mail.password, not SPRING_MAIL_PASSWORD. A key in SCREAMING_CASE is not picked up.
Two consequences worth planning for:
-
A missing credential key is not reported at startup. It surfaces later as an authentication failure that looks like an outage. Keep an in-repo inventory of the required keys so the set is discoverable without reading the chart.
-
Populate the Secret before the chart that stops rendering the value. Add the key with the current value first, so both sources agree and precedence cannot matter; then ship the chart; then remove it from the manifest. Rotate afterwards, as a separate step — a value already in git history must be treated as compromised, but rotating before the chart ships breaks the running deployment.
7. helm-maven-plugin Build
<plugin>
<groupId>io.kokuwa.maven</groupId>
<artifactId>helm-maven-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>init</goal>
<goal>lint</goal>
<goal>template</goal>
<goal>package</goal>
</goals>
<configuration>
<chartDirectory>${project.basedir}/src/main/helm</chartDirectory>
<chartVersion>${project.version}</chartVersion>
<appVersion>${project.version}</appVersion>
<outputDirectory>${project.build.directory}/helm</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
mvn package produces target/helm/event-admin-portal-<version>.tgz.
Publishing to a chart repository: push via helm push to OCI registry or upload to a static S3/GCS repo. Current practice: OCI-style push to the same Docker Hub repo used for the image.
8. Chart Versioning
version = project.version at build time. Every push gets a unique chart version. ArgoCD pins to a specific targetRevision per environment; promotion means bumping the revision in the prod ArgoCD Application manifest.
Do not use floating chart versions in prod ArgoCD manifests. Stage may float to latest-dev for convenience; prod is always pinned.
9. admin-portal Specifics
Create admin-portal/src/main/helm/ with:
-
Chart.yaml—name: event-admin-portal, placeholders for version/appVersion -
values.yaml— defaults leaning dev-friendly -
templates/— copy from registration-portal, adjust port to 12506, ingress host pattern toadmin.event.idealogic.co.za
Initial production values (in the ArgoCD manifest, see ArgoCD Deployment Patterns):
-
config.profiles:"prod,kubernetes,api-docs,otlp" -
config.services.eventadminservice:http://prod-event-admin-service -
config.security.oauth2: enabled with the staff IdP client details -
config.otel: enabled with the in-cluster collector URL
Secrets: create Kubernetes secret event-admin-portal with:
-
apikey— the portal’s X-API-KEY -
oidcclientsecret— OIDC client secret -
jwtencryptionkey— not used by admin-portal if it doesn’t mint JWTs itself (admin-service does); keep for future compatibility
10. Reference
| File | Role |
|---|---|
|
Reference chart for a backend service |
|
Reference chart for a portal |
|
Shared template helpers / defaults (if any at parent level) |
|
Production ArgoCD values — full example |
12. Change History
| Date | Change |
|---|---|
2026-04-24 |
Initial draft. Grounded in admin-service and registration-portal Helm charts. |
2026-09-18 |
Health group membership: an empty |