Kubernetes (Helm) deployment
Operator workflow for the subscription-service Helm chart at deploy/k8s/charts/subscription-service/.
See docs/architecture.md “Kubernetes (Helm)” for the design rationale and deploy/k8s/charts/subscription-service/README.md for the per-value reference.
What the chart deploys
| Workload | Kind | Default image | Notes |
|---|---|---|---|
<release>-postgres | StatefulSet (1 replica) + PVC | postgres:16-alpine | HAPI’s datastore. Headless Service. |
<release>-hapi | Deployment (1 replica) | subscription-service/hapi:dev | FHIR R4 server. ClusterIP Service. |
<release>-matchbox | Deployment (1 replica) | europe-west6-docker.pkg.dev/ahdis-ch/ahdis/matchbox:v3.9.13 | $transform. ClusterIP Service. |
<release>-interface-engine | Deployment (1 replica) | subscription-service/interface-engine:dev | HL7 MLLP listener. Two Services: ClusterIP for HTTP, LoadBalancer for MLLP. |
Plus: two ConfigMaps (HAPI application.yaml + JEP-330 healthcheck), two Secrets (Postgres creds + auth config), and an Ingress fronting HAPI.
Rancher Desktop (local validation, ticket #364)
This is the path the chart was validated on. See the chart README for a copy-pastable quick start; this section is the operator-facing detail.
Prerequisites
kubectl config current-context # must be: rancher-desktophelm version --short # tested with v3.x / v4.xdocker version # tested with 29.xkubectl get nodes -o wide shows the container runtime. Rancher Desktop has two modes:
-
dockerd (moby) — k3s uses dockerd directly. Locally-built docker images are visible to k8s with no extra step.
CONTAINER-RUNTIMEshowsdocker://.... -
containerd — k3s uses its own containerd. Locally-built docker images must be re-loaded:
Terminal window docker save subscription-service/hapi:dev | nerdctl --namespace k8s.io loaddocker save subscription-service/interface-engine:dev | nerdctl --namespace k8s.io loadCONTAINER-RUNTIMEshowscontainerd://....
The chart sets imagePullPolicy: IfNotPresent so neither mode tries to pull a non-existent registry tag.
Build the locally-derived images
docker build -t subscription-service/hapi:dev hapi/docker build -t subscription-service/interface-engine:dev interface-engine/The HAPI image layers our auth/validation/channel-security/multi-tenancy JAR onto hapiproject/hapi:v7.6.0. The interface-engine image is built from the Gradle Spring Boot project in interface-engine/.
Install
helm install subsvc deploy/k8s/charts/subscription-service \ -n subsvc-test --create-namespace \ -f deploy/k8s/charts/subscription-service/values-rancher.yamlThe chart’s init containers will fetch the FHIR IGs from packages.fhir.org into an emptyDir mounted at /app/igs. The values-rancher.yaml overlay sets igFetcherInsecure: true because Rancher Desktop on a corporate-managed Mac (Netskope / Zscaler / etc.) intercepts TLS; without this flag the init container fails with curl: (60) SSL certificate problem. Skipping verification at this step is acceptable: the IGs are content-addressable by version and pinned in values.yaml, and runtime HAPI traffic never goes through the init-container TLS path.
Wait for readiness
kubectl -n subsvc-test rollout status statefulset/subsvc-postgres --timeout=300skubectl -n subsvc-test rollout status deployment/subsvc-matchbox --timeout=300skubectl -n subsvc-test rollout status deployment/subsvc-hapi --timeout=600skubectl -n subsvc-test rollout status deployment/subsvc-interface-engine --timeout=300sHAPI takes the longest (1-3 minutes on a fresh DB) because it has to install the US Core + Subscriptions Backport IGs.
Hosts entry
The chart’s default Ingress host is subscription-service.local. Rancher Desktop’s traefik listens on localhost:80, but you’ll need a hosts entry so the browser/curl sends the right Host: header:
echo "127.0.0.1 subscription-service.local" | sudo tee -a /etc/hostsOr pass -H "Host: subscription-service.local" on every curl, as the smoke test below does.
Smoke test
# 1. CapabilityStatementcurl -sS -H "Host: subscription-service.local" http://localhost/fhir/metadata \ | jq '{resourceType, fhirVersion, software:.software.name, resourceCount:(.rest[0].resource|length)}'# Expected:# { "resourceType": "CapabilityStatement",# "fhirVersion": "4.0.1",# "software": "HAPI FHIR Server",# "resourceCount": 146 }
# 2. POST a Patient, GET it backcurl -sS -H "Host: subscription-service.local" \ -H "Content-Type: application/fhir+json" \ -X POST -d '{"resourceType":"Patient","name":[{"family":"Doe","given":["Jane"]}]}' \ http://localhost/fhir/Patient | jq '.id'
curl -sS -H "Host: subscription-service.local" http://localhost/fhir/Patient/<id> \ | jq '{id, resourceType, name}'
# 3. MLLP round-trip (ADT^A04 -> AA ACK){ printf '\x0b'; printf 'MSH|^~\\&|TESTAPP|TESTFAC|HAPI|HOSP|20260626120000||ADT^A04|MSG00001|P|2.5\rEVN||20260626120000\rPID|1||MRN12345^^^HOSP^MR||Smith^John||19800101|M\rPV1|1|O|||||\r'; printf '\x1c\r'; } \ | nc -w 5 localhost 2575 | xxd | head -5# Expected: bytes show "MSA|AA|MSG00001" — Application Accept.Teardown
helm uninstall subsvc -n subsvc-testkubectl delete namespace subsvc-testThe PVC for Postgres is removed with the namespace; data is gone.
Production / dev clusters
The same pattern works for any dev or production cluster:
-
Push the locally-built images to a registry. Any OCI registry works (Docker Hub, ECR, GCR, GAR, ACR, Harbor, Quay, etc.):
Terminal window docker tag subscription-service/hapi:dev your-registry.example.com/subscription-service-hapi:<tag>docker push your-registry.example.com/subscription-service-hapi:<tag>docker tag subscription-service/interface-engine:dev your-registry.example.com/subscription-service-interface-engine:<tag>docker push your-registry.example.com/subscription-service-interface-engine:<tag> -
Set the registry coordinates and pull policy in
values-dev.yaml/values-prod.yaml:image:hapi:repository: your-registry.example.com/subscription-service-hapitag: <tag>pullPolicy: AlwaysinterfaceEngine:repository: your-registry.example.com/subscription-service-interface-enginetag: <tag>pullPolicy: AlwaysimagePullSecrets:- name: your-registry-credIf the registry needs credentials, create the pull secret once per namespace with the standard kubectl recipe:
Terminal window kubectl create secret docker-registry your-registry-cred \--docker-server=your-registry.example.com \--docker-username=<user> \--docker-password=<password> \--docker-email=<email> \-n subscription-serviceDrop the
imagePullSecretsblock entirely if you’re pulling from a public registry. -
Install / upgrade:
Terminal window helm upgrade --install subsvc deploy/k8s/charts/subscription-service \-n subscription-service --create-namespace \-f deploy/k8s/charts/subscription-service/values-dev.yaml -
Wait for rollout, then verify CapabilityStatement on the public hostname:
Terminal window curl -fsS https://subscription-service.example.com/fhir/metadata | jq .fhirVersion -
Configure auth (
featureToggles.auth.issuer->https://your-keycloak.example.com/realms/subscription-service) and feature toggles per environment. -
For cloud deployments, point HAPI at a managed Postgres (RDS, Cloud SQL, Azure DB for PostgreSQL, etc.) rather than running the in-cluster StatefulSet. This is the expected production path: managed services give you automated backups, point-in-time recovery, HA, and patch management out of the box. Flip
externalPostgres.enabled: truein your values and pre-create the password Secret as described in the chart README’s External Postgres section. The chart will skip its own Postgres StatefulSet/Service/Secret and wire HAPI to the host you specify.
TLS via cert-manager (ticket #415)
If your cluster runs cert-manager with a ClusterIssuer configured (typical on managed clusters), the chart can auto-provision the Ingress TLS cert via ACME / Let’s Encrypt. The chart does not install cert-manager — that’s a platform-level prerequisite — but once it’s present you just flip a toggle.
# values-dev.yaml (or values-prod.yaml)ingress: enabled: true className: nginx # or traefik, alb, etc. hosts: - host: subscription-service.example.com paths: [{ path: /, pathType: Prefix }] certManager: enabled: true clusterIssuer: letsencrypt-prod # name of an existing ClusterIssuerhelm upgrade --install then renders an Ingress with cert-manager.io/cluster-issuer: letsencrypt-prod plus a tls block pointing at <release>-hapi-tls. cert-manager watches the Ingress, requests a cert from Let’s Encrypt, and writes it to that Secret in the same namespace; the ingress controller picks up the new cert automatically.
Use issuer: instead of clusterIssuer: if you’ve created a namespace-scoped Issuer. To bring your own Secret (sealed-secrets, external-secrets, etc.), leave certManager.enabled: false and populate ingress.tls directly — see the chart README “TLS (cert-manager)” section for the trade-off.
Pod Security Standards
The chart’s default podSecurityContext / securityContext blocks satisfy the Pod Security Standards restricted profile, so it installs cleanly on GKE Autopilot, OpenShift, and any namespace labeled pod-security.kubernetes.io/enforce=restricted. Each workload runs as its image’s expected non-root UID (HAPI 65532, matchbox 1000, interface-engine 10001, postgres 70) with allowPrivilegeEscalation: false, all Linux capabilities dropped, and the RuntimeDefault seccomp profile. See the chart README’s Pod Security Standards section for the per-workload UID table and override mechanics.
Troubleshooting
Init:CrashLoopBackOff on hapi / matchbox
The fetch-igs init container failed. Inspect:
kubectl -n <ns> logs <pod> -c fetch-igsCommon causes:
| Symptom | Fix |
|---|---|
curl: (60) SSL certificate problem | TLS-inspecting proxy in the path. Set hapi.igFetcherInsecure: true and matchbox.igFetcherInsecure: true (the values-rancher.yaml overlay already does this for local laptops). |
curl: (6) Could not resolve host: packages.fhir.org | No DNS/internet egress from the pod network. Configure cluster DNS or mirror the packages to an internal registry and override igRegistry. |
curl: (22) HTTP 404 | Bad package name or version. Verify <igRegistry>/<name>/<version> returns a tarball in a browser. |
ImagePullBackOff on hapi / interface-engine
The locally-built image is missing from the cluster’s image store. In dockerd-mode Rancher Desktop, just docker build. In containerd-mode, do the docker save | nerdctl --namespace k8s.io load dance documented above.
HAPI pod takes 90+ seconds to become Ready
Normal. The IG install on a fresh database takes a while. The chart’s readiness probe has initialDelaySeconds=60, periodSeconds=10, failureThreshold=30 (so HAPI has 60 + 30*10 = 360 seconds of grace before the pod is marked unhealthy).
helm upgrade leaves old ReplicaSets running
If helm upgrade changes immutable fields (e.g. emptyDir vs configMap volume), the Deployment will keep an old broken ReplicaSet around alongside the new one. Drop the old ones:
kubectl -n <ns> get rskubectl -n <ns> delete rs <name-of-old-rs>A normal rolling update (image bump, env-var change) handles this automatically.
LoadBalancer Service has no EXTERNAL-IP
On a real cloud cluster, that’s the cloud-provider LB controller hasn’t picked it up yet — wait 1-2 minutes. On Rancher Desktop, klipper-lb gives every LoadBalancer the node’s external IP (192.168.64.2 or similar) and also binds the port on the host (so localhost:2575 works).
Acceptance evidence (#363 + #364)
| Check | Result |
|---|---|
helm template deploy/k8s/charts/subscription-service | 13 manifests render |
helm template ... -f values-rancher.yaml | 13 manifests render |
helm template ... -f values-dev.yaml | 13 manifests render |
helm template ... | kubectl apply --dry-run=client -f - | All 14 objects valid |
helm lint deploy/k8s/charts/subscription-service | 0 errors, 0 warnings |
helm install subsvc -n subsvc-test ... values-rancher.yaml | All 4 pods Ready |
GET /fhir/metadata via traefik ingress | HTTP 200, CapabilityStatement, FHIR 4.0.1, 146 resource types |
POST /fhir/Patient, GET /fhir/Patient/<id> | HTTP 201 then HTTP 200, round-trip OK |
ADT^A04 over MLLP nc localhost 2575 | AA ACK returned |
helm uninstall && kubectl delete ns | Clean teardown |