Kubernetes & Helm
The deploy/helm/ferroehr chart deploys FerroEHR as a hardened,
production-shaped Kubernetes workload: non-root, read-only root filesystem,
default-deny ingress, connecting to an external PostgreSQL 18. This chapter
covers installing the chart, the database role model it expects, the security
posture it enforces, the health probes, and the optional integrations. It
assumes a cluster at Kubernetes 1.25 or newer.
Important
There is no in-chart PostgreSQL. A CDR stores PHI, so its database must be an externally managed, backed-up, point-in-time-recoverable PostgreSQL 18 (a managed service or an operator-run cluster). The chart carries only the connection string, preferably from an existing Secret.
Installing
Create a Secret holding the app-role connection string, then install the chart pointing at it:
kubectl -n ferroehr create secret generic ferroehr-db \
--from-literal=FERROEHR__DB__URL='postgres://ferroehr_app:***@pg-host:5432/ferroehr?sslmode=verify-full'
helm install ferroehr deploy/helm/ferroehr -n ferroehr \
--set database.existingSecret=ferroehr-db \
--set image.tag=3.5.0
Always pin image.tag to an immutable version or, better, a @sha256 digest —
never latest. Every values.yaml key documents the exact FERROEHR_*
environment variable it maps to; the configuration reference
is the full list.
Database roles — who runs migrations
The chart expects a four-role PostgreSQL model, so the runtime pod is never a superuser:
| Role | Purpose |
|---|---|
| owner | owns the database (provisioning only) |
ferroehr_migrator | runs the append-only schema migrations |
ferroehr_app | day-to-day reads and writes — the running pod connects as this |
ferroehr_reader | read-only, for replicas and reporting |
The binary calls its migrations on boot, so you choose one of two flows:
- (a) Grant the runtime DSN the migrator role — simplest for single-tenant or small deployments; the pod migrates itself at startup.
- (b) Run migrations out of band with a migrator DSN (a CI step or a
one-shot
Job), then start the pods with the lower-privilegedferroehr_appDSN — recommended for least-privilege production. Gate the Deployment rollout on the migration step so two server versions never race the schema.
The chart does not ship a migration Job; migrations.runByMigratorRole is an
informational marker surfaced in the install NOTES.
Secrets and mounted config
Some settings do not fit cleanly in environment variables — the Basic-auth user
store, a full OIDC block, RBAC role-claim lists, ABAC policies, the external
terminology provider map, ATNA TLS certificates, and the PGP signing key. Supply
these as files via config.files, which the chart mounts read-only from a
Secret at /etc/ferroehr/<key>; point the matching in-TOML *_file /
*_path key (or its FERROEHR__… env override) at the mounted path. Secret-bearing scalar values
(DB DSN, HMAC secret, broker URLs, S3 keys, PGP passphrase) go into the chart’s
Secret, never the ConfigMap.
Security posture
The chart pins — and its validate.sh gate asserts on every render — the
following:
| Field | Value |
|---|---|
runAsNonRoot | true (uid/gid 65532, the distroless nonroot user) |
readOnlyRootFilesystem | true (a writable emptyDir is mounted at /tmp) |
allowPrivilegeEscalation | false |
capabilities.drop | [ALL] |
seccompProfile.type | RuntimeDefault (pod and container) |
| ServiceAccount token | not mounted (the workload never calls the K8s API) |
| NetworkPolicy | default-deny ingress; only the API (and management) port admitted |
Egress restriction is opt-in (networkPolicy.egress.enabled) because egress
targets — the database, broker, terminology server — are deployment-specific;
when you enable it the chart always admits DNS and you add rules for the rest.
The database-side controls (TLS with sslmode=verify-full, pgaudit, at-rest
encryption, WAL archiving / PITR) belong to whoever provisions PostgreSQL — the
chart references them but cannot enforce them. See
Operations.
Health probes
Probes use the always-on, unauthenticated, PHI-free health routes on the main HTTP port. They need no configuration at all — no management surface, no access level, nothing to forget:
| Probe | Route | Contract |
|---|---|---|
| liveness | /health/liveness | 200 while the process is up; touches no dependency |
| readiness | /health/readiness | 200 (UP/DEGRADED) or 503 (DOWN): checks DB ping, migrations applied, audit sender, events — each 1s-bounded |
| startup | /health/liveness | gates a slow first boot |
That split is deliberate: a database outage must fail readiness (the pod stops
receiving traffic) and never liveness (which would restart the container in a
loop). If the kubelet cannot reach the HTTP port, set probes.exec.enabled=true
to use the binary’s healthcheck subcommand instead.
The management surface is independent of the probes and stays ops-only
(/management/info, /prometheus, /metrics, /env, /loggers). Set
metrics.enabled=true to expose {management.basePath}/prometheus (access
level public) with the prometheus.io/* scrape annotations; the other
endpoints stay admin_only or off unless you opt in. Set management.port to
serve the surface on its own internal listener, so /management is never
reachable on the clinical API port — the health probes stay on the main port
regardless.
Optional integrations
Every integration is off by default, matching the binary — enabling one is
an explicit, auditable decision. Each has a values.yaml switch and maps to a
config env prefix:
| Integration | Values key | Notes |
|---|---|---|
| ADMIN API | rest.adminEnabled | Physical, irreversible delete. Gate behind admin RBAC. |
| Terminology extension API | rest.terminologyEnabled | 404 when off. |
| Event-subscription API | rest.eventSubscriptionEnabled | Admin CRUD over event filters. |
| Multi-tenancy | tenancy.enabled | Tenant from a JWT claim; leave tenancy.header unset in prod. Pairs with PG row-level security. |
| OAuth2/OIDC auth | auth.oidc.* | Prefer JWKS/discovery over an HS256 secret. |
| ABAC | authz.abac.enabled | Cedar or a remote policy decision point; policies via a mounted TOML. |
| Eventing → AMQP | events.enabled | Envelopes are PHI-free by design. Use amqps:///tls=true. |
| FHIR inbound/façade | rest.fhirEnabled | Read façade + inbound mapping. |
| FHIR outbound → AMQP | fhirOutbound.enabled | ⚠ Carries PHI (the mapped FHIR resource). Separate exchange; TLS broker only. |
| S3 multimedia | multimedia.enabled | ⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket. |
| External terminology | externalTerminology.enabled | FHIR terminology server; provider map via a mounted TOML. |
| ATNA system log | audit.enabled | Use audit.syslog.transport: tls for PHI-adjacent audit. |
| Version signing | signing.* | On by default (digest). pgp mode fails closed at boot without a usable key. |
| OTLP telemetry | telemetry.otel.* | Unset endpoint ⇒ the OTel layer is not installed (zero overhead). |
Full detail on each is in Beyond the core, Security & multi-tenancy, and Operations.
Upgrades
Migrations are append-only — a schema change is a new file, never an edit to
an applied one — so a rolling upgrade stays compatible with the previous schema
during the window where both versions run: additive DDL first, destructive
changes in a later release once all pods are on the new version. Keep
replicaCount >= 2 (or autoscaling) and the default PodDisruptionBudget so
upgrades and node drains never fully interrupt the API; the default
terminationGracePeriodSeconds covers the binary’s shutdown drain. Roll back by
re-pinning the prior image tag or digest.
Render and validate the chart before applying with deploy/helm/validate.sh
(helm lint + template + the security-field gate + golden-render diff).