Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

RolePurpose
ownerowns the database (provisioning only)
ferroehr_migratorruns the append-only schema migrations
ferroehr_appday-to-day reads and writes — the running pod connects as this
ferroehr_readerread-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-privileged ferroehr_app DSN — 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:

FieldValue
runAsNonRoottrue (uid/gid 65532, the distroless nonroot user)
readOnlyRootFilesystemtrue (a writable emptyDir is mounted at /tmp)
allowPrivilegeEscalationfalse
capabilities.drop[ALL]
seccompProfile.typeRuntimeDefault (pod and container)
ServiceAccount tokennot mounted (the workload never calls the K8s API)
NetworkPolicydefault-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:

ProbeRouteContract
liveness/health/liveness200 while the process is up; touches no dependency
readiness/health/readiness200 (UP/DEGRADED) or 503 (DOWN): checks DB ping, migrations applied, audit sender, events — each 1s-bounded
startup/health/livenessgates 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:

IntegrationValues keyNotes
ADMIN APIrest.adminEnabledPhysical, irreversible delete. Gate behind admin RBAC.
Terminology extension APIrest.terminologyEnabled404 when off.
Event-subscription APIrest.eventSubscriptionEnabledAdmin CRUD over event filters.
Multi-tenancytenancy.enabledTenant from a JWT claim; leave tenancy.header unset in prod. Pairs with PG row-level security.
OAuth2/OIDC authauth.oidc.*Prefer JWKS/discovery over an HS256 secret.
ABACauthz.abac.enabledCedar or a remote policy decision point; policies via a mounted TOML.
Eventing → AMQPevents.enabledEnvelopes are PHI-free by design. Use amqps:///tls=true.
FHIR inbound/façaderest.fhirEnabledRead façade + inbound mapping.
FHIR outbound → AMQPfhirOutbound.enabledCarries PHI (the mapped FHIR resource). Separate exchange; TLS broker only.
S3 multimediamultimedia.enabled⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket.
External terminologyexternalTerminology.enabledFHIR terminology server; provider map via a mounted TOML.
ATNA system logaudit.enabledUse audit.syslog.transport: tls for PHI-adjacent audit.
Version signingsigning.*On by default (digest). pgp mode fails closed at boot without a usable key.
OTLP telemetrytelemetry.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).