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 ferroehr Helm chart deploys FerroEHR as a hardened, production-shaped Kubernetes workload (non-root, read-only root filesystem, a NetworkPolicy that admits its serving port only, its own user namespace) against an external PostgreSQL 18. This chapter covers installing it, verifying what you installed, the database role model it expects, the security posture it pins, the health probes, the optional integrations, and upgrades.

Important

The chart requires Kubernetes 1.36 or newer (kubeVersion: ">=1.36.0-0"). That is a compatibility floor, not a support opinion: 1.36 is the release where the newest field the chart renders (hostUsers, which gives every pod its own user namespace) became stable, so nothing has to be version-gated into silence. Your nodes must be Linux with containerd 2.0 or newer, or CRI-O 1.25 or newer; without that support the pod does not start, which is the loud failure rather than a silent downgrade. Set hostUsers: true to opt out and share the host’s user namespace. What the user namespace buys is in Cluster hardening.

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

The chart is published to GHCR as an OCI artifact, beside the images it deploys. Create a Secret holding the app-role connection string, then install:

kubectl create namespace ferroehr
kubectl -n ferroehr create secret generic ferroehr-db \
  --from-literal=FERROEHR__DB__URL='postgres://ferroehr_clinical:***@pg-host:5432/ferroehr?sslmode=verify-full'

helm install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
  --version 21.0.0-rc3 -n ferroehr \
  --set config.deployment_profile=sandbox \
  --set database.existingSecret=ferroehr-db \
  --set image.tag=5.6.0-rc3 \
  --set ferropix.enabled=false --set config.ehr_index.provider=local

config.deployment_profile=sandbox names the posture of a trial install, which must not hold real patient data; the chart has no default and refuses to render without one. A deployment holding patient data starts from the production overlay instead. The last line opts in to the in-instance EHR index, for a CDR run on its own. By default the chart runs FerroPIX, the IHE patient identity source, beside the CDR and points the EHR index at it, which needs FerroPIX’s own Secrets and an issuer: see FerroPIX. With neither, the render is refused and names both choices, as the server’s boot would be.

Important

helm repo add does not work for this chart, and never will. There is no HTTP chart repository and no index.yaml: OCI is the only publication path, so that there is exactly one place a chart version can exist. Every command takes the oci:// reference above. Helm has treated OCI registries as first-class since 3.8, so the cost of this choice is real but narrow: a client older than Helm 3.8 cannot install this chart at all.

helm show, helm pull, helm template and helm upgrade all take the same reference. To read the chart’s metadata without installing it:

helm show chart oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3

Pin two versions, not one

The chart version and the image tag move independently, and this is the thing people get wrong first. --version selects the chart (its templates and values schema); image.tag selects the server binary. The chart’s appVersion is only the default for the second, and it names the release the chart was cut against.

SelectsPin withLine
Chart versiontemplates, values schema, defaults--version 21.0.0-rc3SemVer over the chart’s own contract
Image tagthe server binary--set image.tag=5.6.0-rc3 (or image.digest)the application’s SemVer line

Always pin the image to an immutable version or, better, a @sha256 digest, never latest. Pin the two deliberately: the config tree is passed through to the server, so a key the chart’s defaults carry and your chosen image does not know is a boot refusal (unknown configuration key …), which presents as CrashLoopBackOff. A chart version is never republished with different content (the publish lane refuses to overwrite one) so a pinned chart version is a fixed artifact.

Note

A published chart version being immutable is a property of that refusal, not of the registry: an OCI tag is mutable, and helm push over an existing one would silently replace it. That is why a correction always ships as a new chart version, and why the 6.0.3 → 6.0.4 bump exists: the chart’s appVersion moved to a new release while its own version had already been published.

Verifying what you installed

Everything published here is signed keyless through Sigstore, bound to this repository’s build identity, so every claim is checkable rather than asserted. Two different artifacts answer two different questions, and you can ask both.

Who signed this chart: a cosign signature over the chart’s digest:

cosign verify ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
  --certificate-identity-regexp '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/build-chart\.yml@' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Both flags matter. Without them cosign verify would accept a signature from any identity in the transparency log; with them you are requiring that this repository’s chart-publishing workflow, authenticated by GitHub’s OIDC issuer, is what signed the bytes you pulled.

What it was built from: a SLSA build provenance attestation:

# the chart
gh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<chart-version> -R FerroHEALTH/FerroEHR
# the image it deploys
gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:<tag> -R FerroHEALTH/FerroEHR

The publish lane reads both back from the registry before it reports success, so a run that produced only one of them fails instead of going green.

Important

Both commands verify what the publishing lanes produce now. A published artifact is never replaced, so if one you pinned answers HTTP 404: Not Found it carries no attestation, which is the honest state rather than a verification failure, and the fix is to pin a current version. Image signing landed during the 3.17.4 cycle, so image tags from before it have nothing to verify.

Note

helm install --verify and helm verify do not apply: they check a PGP .prov file, and this chart ships none. That is deliberate: a .prov needs a long-lived private key in CI, which is the exact thing this project’s publishing lanes are built to avoid (the crates.io lane uses OIDC Trusted Publishing and holds no token at all). The two keyless commands above are what replace it: nothing to leak, and the same trust root as the images.

Your values file is checked before anything is applied

The chart ships a values.schema.json, so helm install, helm upgrade, helm lint and helm template refuse a values file that misspells one of the chart’s own keys, gets a type wrong, or names a value outside the permitted set, rather than rendering and silently ignoring it:

Error: values don't meet the specifications of the schema(s) in the following chart(s):
ferroehr:
- at '/image/pullPolicy': value must be one of 'Always', 'IfNotPresent', 'Never'

The config: tree is deliberately exempt. Those keys are the server’s (see the configuration reference), the binary validates them at boot, and copying that vocabulary into the chart’s schema would fork it: a new configuration key would then be rejected by the chart until someone remembered to widen the schema. So a mistake under config: is reported when the pod starts, not when the chart renders; --skip-schema-validation disables the check entirely if you ever need to bypass it.

The chart is also listed on Artifact Hub, which renders the chart’s metadata plus a security report over the four images the chart’s own metadata lists: the server, the optional viewer, the backup jobs’ pg_dump image, and FerroTERM.

Warning

Between releases the chart’s config defaults track development and can be ahead of appVersion’s image. Check the pairing before you install, with the image itself as the authority:

helm template ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 \
  -s templates/configmap.yaml --set database.existingSecret=ferroehr-db \
  | sed -n '/ferroehr.toml/,$p' | sed '1d;s/^    //' > /tmp/ferroehr.toml
docker run --rm -v /tmp/ferroehr.toml:/etc/ferroehr/ferroehr.toml:ro \
  -e FERROEHR_CONFIG=/etc/ferroehr/ferroehr.toml \
  -e FERROEHR__DB__URL=postgres://u:p@db:5432/ferroehr \
  --entrypoint /usr/local/bin/ferroehr ghcr.io/ferrohealth/ferroehr:<tag> config check

Exit 0 means the image accepts the rendered configuration. A reported unknown key means the image is older than the chart’s defaults: use a newer tag, or set that key to null for this deployment. A published chart is checked this way before it is published: the publish lane refuses to ship a chart whose defaults its own appVersion image rejects, and it repeats the check against every values overlay the chart carries, so this matters mainly when you install from a checkout of main, or pin an image older than the chart.

That install alone boots but answers 401 to everything, deliberately: config.auth.enabled is on and no mechanism is configured yet, and a server that authenticates nothing is not a safe default. Add a config.auth.issuer (with config.auth.audience) or a config.auth.basic.users entry before expecting a request to succeed. Every route also checks the SMART on openEHR scopes of the caller: a Basic user’s scopes, a token’s scope claim.

Configuration: one key, one file

The chart carries the server’s whole configuration under one key, config, rendered into a ferroehr.toml ConfigMap mounted at /etc/ferroehr/ferroehr.toml. Its keys are therefore exactly the TOML keys of the configuration reference (config.server.listen, config.authz.rbac.enabled, config.spec_profile, and so on) so anything that reference documents can be set without waiting for a bespoke chart key.

Secret-bearing keys are the one exception: a credential never reaches that ConfigMap. A ConfigMap is not a sensitive object: it is readable with namespace read, quoted wholesale into issues and support tickets, collected by backup tooling that skips Secrets, and not covered by Secret encryption at rest. The chart therefore classifies every key it renders and takes one of two actions.

A secret the chart routes: signing.key_passphrase, multimedia.secret_access_key, a Basic user’s password_hash, a terminology client_secret, and the three URL-shaped ones (db.url, events.url, audit.fhir_feed.url) has a secrets: key of its own, so a value under config: is a mistake and fails the render, naming the key that belongs there:

Error: execution error at (ferroehr/templates/deployment.yaml:…):
  refusing to render a secret into the ConfigMap (a ConfigMap is not a sensitive object …):
  - config.signing.key_passphrase: set `secrets.signingKeyPassphrase` instead

A secret the chart cannot route moves the whole rendered ferroehr.toml into the chart’s Secret, and no ConfigMap is created at all, the safe direction, taken automatically. No key reaches that branch today: every credential the server models now has either a *_file sibling or a Secret-borne environment route. The branch exists for the next one that does not, so that a secret key added upstream fails safe instead of landing in a ConfigMap. When it is taken, the install notes say which object your release used, and the configuration is read with:

kubectl -n ferroehr get secret ferroehr-config \
  -o jsonpath='{.data.ferroehr\.toml}' | base64 -d

Classification is by name shape, not by a list of today’s keys: any key whose name carries password, passphrase, secret, credential, private_key, api_key or a trailing token is treated as a credential unless it ends in _file, _path or _dir (those hold a path), and the four URL-shaped secrets are matched by path, because url carries no shape a classifier can see. That is what makes a secret key added to the server’s configuration tree in a future release move to the Secret rather than leak silently. extraEnv is the escape hatch for anything neither config: nor secrets: surfaces.

The deployment profile and the access log

The chart’s values declare three keys, so a rendered ferroehr.toml always says what it runs:

  • config.deployment_profile: no default; the chart refuses to render until it names sandbox or production, so an installation that follows a quickstart instead of the instructions for use does not end up in the sandbox posture by omission (EHDS Annex II 1.2). sandbox must not hold real patient data: the server names every separation it has not made on its boot banner and on /management/status, and reports the profile on GET /ferroehr/rest/status. A deployment holding patient data sets production, which refuses to start while a separation is missing and not named in config.deployment_accepts. The chart defaults leave several open: one DSN for every domain, an empty config.privacy.subject_namespaces, the server’s open defaults for config.audit.fail_mode and config.authz.rbac.ehr_access_default, config.db.migrate: apply on the runtime credential, and a plaintext listener behind the ingress; see deployment_profile. The chart’s production overlay closes them and accepts the plaintext listener by name.
  • config.audit.store.retention_years: unset. Set it in place of config.audit.store.retention_days when the horizon must be exact in calendar years, such as 3 for a German SGB V § 309 controller with config.audit.store.sgb_v_309_controller: true.
  • config.audit.categories: an empty templates and archetypes map, under which the category map FerroEHR ships applies: it classifies the published CKM templates and archetypes of one priority category and records anything else as unclassified. Map your own templates and root archetypes, which replaces the shipped map; the keys are template ids and archetype ids, so quote a template id that contains spaces:
config:
  audit:
    categories:
      templates:
        "International Patient Summary": [patient-summary]
      archetypes:
        openEHR-EHR-COMPOSITION.report-result.v1: [test-results]

The Audit page has the category vocabulary and the retention floors and ceilings the server enforces at boot.

The production overlay

The chart ships values-production.yaml beside values.yaml. Layered over the defaults, it runs config.deployment_profile: production with every separation that profile checks made, except one it accepts by name:

GapIn the overlay
shared_credentialthe clinical, party and linkage domains each read their own Secret (database.existingSecret, database.party.existingSecret, database.linkage.existingSecret), and the audit domain reads database.audit.existingSecret
shared_clusteryours to make: the server compares the cluster each domain pool reaches, so the separation is four DSNs on four PostgreSQL clusters. Each cluster is prepared on its own migrator: the migration Job applies the schema in every cluster, and the server verifies it there, on database.<domain>.migrateExistingSecret (database.migrateExistingSecret for the clinical cluster). The linkage cluster also receives the party migration set, schema only, because the linkage set names a function the party set creates
migrate_on_runtime_credentialconfig.db.migrate: verify, with migrations.job.enabled preparing the schema on database.migrateExistingSecret
open_subject_namespaceconfig.privacy.subject_namespaces declared
audit_off, audit_fails_openthe audit trail on with its local store, config.audit.fail_mode: closed
no_category_mapthe shipped category map (the default), or config.audit.categories templates and archetypes of your own
open_ehr_access_defaultconfig.authz.rbac.ehr_access_default: restricted
auth_offOIDC authentication on
rbac_offthe role gate on (config.authz.rbac.enabled: true, the chart’s default)
plaintext_brokerno AMQP integration on
plaintext_audit_feed, plaintext_object_storethe audit FHIR feed and the multimedia store off; an https:// URL for either when you turn it on
plaintext_listeneraccepted by name: TLS ends at the Ingress (ingress.tls), the pod listens in plain HTTP, and the NetworkPolicy admits only the ingress controller’s namespace (networkPolicy.ingressFrom, with networkPolicy.ingressAllowAll: false)

The database hop is TLS with the server’s certificate checked: end every DSN in ?sslmode=verify-full&sslrootcert=/etc/ferroehr-db-ca/ca.crt, and put the CA your database certificates chain to in a ConfigMap under the key ca.crt. The overlay names it ferroehr-db-ca (database.caConfigMap), and the chart mounts it read-only at /etc/ferroehr-db-ca in the server, the migration Job and the backup CronJobs. A CA the image already trusts needs no ConfigMap; leave database.caConfigMap empty then.

Every Secret name, the CA ConfigMap name, the identity provider, the pseudonym namespace, the host, the TLS Secret and the ingress controller’s namespace in it are examples. Replace them in a values file of your own and pass both:

helm pull oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 --untar
helm install ferroehr ./ferroehr -n ferroehr \
  -f ./ferroehr/values-production.yaml -f my-site-values.yaml

A name you forget to replace fails loudly: a Secret that does not exist keeps the pod from starting, and the server refuses to start under production while a separation is open, naming each gap and its remedy. The chart’s validation renders the overlay and asserts each separation above, and its boot check has the server evaluate the posture from the rendered configuration. Neither reaches a database. The deployment probe (scripts/deploy-probe-k8s.sh --production-only) installs the overlay on a real cluster against four PostgreSQL clusters that refuse a connection without encryption: it observes the migration Job prepare all four and the server start under production with plaintext_listener as its only gap, both over sslmode=verify-full against the CA the overlay mounts, and the server refuse all four domains on one cluster by naming shared_cluster. Against your own databases, shared_cluster is first judged when the pod starts.

Database roles — who runs migrations

The chart expects a PostgreSQL role model in which the runtime pod is never a superuser. Four roles cover the single-domain posture:

RolePurpose
ownerowns the database (provisioning only)
ferroehr_migratorruns the append-only schema migrations
ferroehr_clinicalday-to-day reads and writes; the running pod connects as this
ferroehr_clinical_readerread-only, for replicas and reporting

Migrations are DDL, so whoever applies them can rewrite the schema. Two flows, and config.db.migrate is where you choose:

  • (a) The pod migrates itself (config.db.migrate: apply, the default). Simplest for single-tenant or small deployments, and the runtime DSN must then be a member of ferroehr_migrator, so the serving process holds DDL rights on the clinical schema for its whole life.
  • (b) A separate migration step (config.db.migrate: verify) under a migrator DSN, with the pods on a narrow runtime DSN. The server issues no DDL at all and refuses to boot against a database that has not been migrated to its build, so the two versions cannot race the schema. Recommended for production. It needs database.migrateExistingSecret as well, for the reason the paragraph further down gives: verify reads all five bookkeeping tables, which a narrow runtime role cannot do.

Set migrations.job.enabled and the chart runs (b) for you as a pre-install,pre-upgrade hook Job that runs ferroehr db migrate. Helm creates it before the Deployment and waits for it, so a failed migration fails the release rather than rolling pods against a schema that was never applied. The Job authenticates from its own Secret (deliberately a different credential from database.existingSecret) and rendering is refused if you enable it without one:

database:
  existingSecret: ferroehr-db                 # postgres://ferroehr_clinical:…
  migrateExistingSecret: ferroehr-db-migrator # postgres://ferroehr_migrator:…
migrations:
  job:
    enabled: true
    existingSecret: ferroehr-db-migrator      # the same credential, its own Secret mount
config:
  db:
    migrate: verify

The demographic and linkage domains take their own credentials the same way, and that is what turns the schema separation into a credential separation. One more goes with them, because no runtime credential can prepare the schema:

database:
  existingSecret: ferroehr-db             # postgres://ferroehr_clinical:…
  party:
    existingSecret: ferroehr-db-party     # postgres://ferroehr_party:…
  linkage:
    existingSecret: ferroehr-db-linkage   # postgres://ferroehr_linkage:…
  migrateExistingSecret: ferroehr-db-migrator        # postgres://ferroehr_migrator:…

All four are mounted as files, so no DSN enters the pod’s environment. Unset the demographic and linkage ones and those two pools share the first, which is the schema-only posture, still separated and still verified at boot. Creating the five domain roles is a database step the chart cannot do for you; Operations has the statements and what ferroehr db verify reports.

database.migrateExistingSecret is what the pod prepares the schema with, and it is required as soon as database.existingSecret is anything narrower than a credential reaching every schema. Preparation spans them all at once: the DDL of all five migration sets under config.db.migrate: apply, all five _sqlx_migrations bookkeeping tables under verify. So verify is not the exception, and this is not only about the domain roles. ferroehr_clinical holds one pseudonymisation domain, ferroehr_clinical holds the clinical schemas, and neither can read the demographic or linkage bookkeeping at all; without this value the pod is refused on the first set it cannot read and does not start. It is normally the same credential migrations.job.existingSecret carries; the Job needs its own copy because it runs before the Deployment exists. Unset, preparation falls back to database.existingSecret, which is what a single-credential install has always done.

database.migrateExistingSecret reaches one database. A domain whose DSN reaches another database or cluster is prepared there, on that database’s own migrator, which database.<domain>.migrateExistingSecret names:

database:
  linkage:
    existingSecret: ferroehr-db-linkage            # postgres://ferroehr_linkage:…@linkage-db/…
    migrateExistingSecret: ferroehr-db-linkage-migrate   # postgres://ferroehr_migrator:…@linkage-db/…

The migration Job mounts it and applies the schema in that database; the server mounts it and verifies the schema there under config.db.migrate: verify. The domain’s own role cannot do either, because it holds one domain and cannot read the ext set’s bookkeeping. The server refuses to start when the two DSNs of one domain reach different databases. A database that holds the linkage domain without the party domain also receives the party migration set, its schema and grants and never a row, because the linkage set names a function the party set creates.

Give the migrator DSN a short lock_timeout (?options=-c%20lock_timeout%3D5s) so DDL blocked behind live traffic fails fast instead of queueing; migrations.job.activeDeadlineSeconds is the hard ceiling on the step either way. migrations.runByMigratorRole remains an informational marker surfaced in the install notes, which also tell you when the Job is enabled but config.db.migrate is still apply, a combination that buys nothing, because the server would migrate itself anyway.

You can check the posture from outside the cluster at any time: ferroehr db verify exits 0 only when the database carries exactly that build’s migrations, and issues no DDL doing it.

Secrets and mounted config

Some material is file-shaped rather than a value: ABAC policy files, ATNA TLS certificates, terminology-server client certificates, a JWKS blob, the PGP signing key, and a commercial licence token. Supply these under config.files, whose entries the chart mounts read-only from a Secret at /etc/ferroehr/<key> (and which is deliberately not part of the rendered TOML); point the matching in-TOML *_file / *_path key at the mounted path. A commercial licence goes the same way: put the token the licensor issued under config.files as licence.asc and set config.licence.file to /etc/ferroehr/licence.asc; GET /management/status then reports licence.use = "commercial". Without it the pod runs under the non-commercial grant every build embeds (see Licensing & legal). Secret-bearing scalar values go under secrets:: signingKeyPassphrase, eventsUrl, auditFhirFeedUrl, basicUserPasswordHashes, multimediaAccessKeyId, multimediaSecretAccessKey, terminologyClientSecret, and the database DSN comes from database.existingSecret (key database.existingSecretKey, default FERROEHR__DB__URL). None of these ever reach the ConfigMap, and all but two are delivered as mounted files rather than environment values.

How a secret reaches the process differs by whether the configuration key has a *_file sibling, and the difference is a security one: an environment variable is readable through /proc/<pid>/environ and is inherited by every child process, so the OWASP Kubernetes Security Cheat Sheet asks for a read-only volume instead.

SecretHow the chart delivers it
the database DSNmounted at /etc/ferroehr-secrets/db.url, reached through FERROEHR__DB__URL_FILE, projected from database.existingSecret when you supply one, so the credential that reaches patient data never enters the pod’s environment
secrets.signingKeyPassphrasemounted at /etc/ferroehr-secrets/signing.key_passphrase
secrets.multimediaSecretAccessKeymounted at /etc/ferroehr-secrets/multimedia.secret_access_key
secrets.basicUserPasswordHashes (per user-id)mounted at /etc/ferroehr-secrets/auth.basic.users.<user>.password_hash; the chart injects the matching password_hash_file
secrets.terminologyClientSecretmounted at /etc/ferroehr-secrets/terminology.server.oauth.client_secret; the chart injects the matching client_secret_file into config.terminology.server.oauth, which must declare the grant
secrets.eventsUrlmounted at /etc/ferroehr-secrets/events.url
secrets.auditFhirFeedUrlenv; audit.fhir_feed.url is the only credential-bearing key with no *_file sibling
secrets.multimediaAccessKeyIdenv; an access key id is not secret (it is reported unredacted by the management surface’s env endpoint)

The mount is read-only, 0440, owned root:65532 so the non-root process reads it through the group bit, and it is deliberately not a subPath mount, because a subPath-mounted Secret never receives updates and a rotation would not propagate.

Note

A Basic user’s Argon2id hash is delivered as a mounted file like the others: put it in secrets.basicUserPasswordHashes under the user-id, declare only the user, its scopes and its roles under config.auth.basic.users. Setting password_hash under config: is refused, and the error names the key that carries it. An Argon2id hash is not a plaintext password, but it is an offline cracking target, which is what the boot-time OWASP parameter floor exists to make expensive.

Security posture

The chart pins the following, and its render gate holds it: the Restricted fields are asserted per container for every workload in the render, the two isolation settings are asserted to agree across a release’s workloads, and the golden renders pin the exact bytes so a changed default fails a diff.

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)
hostUsersfalse; the pod gets its own user namespace
supplementalGroupsPolicyStrict; only the groups the manifest names
ServiceAccount tokennot mounted (the workload never calls the K8s API)
enableServiceLinksfalse (see below; not a preference)
NetworkPolicyports narrowed to the API (and management) port; sources admitted unless you narrow them with networkPolicy.ingressFrom; set networkPolicy.ingressAllowAll: false to have the chart refuse the open state (see §Ingress)

The whole set satisfies the Pod Security Restricted profile, and the deployment probe harness reads it back off a running pod rather than off the rendered manifest: the container runtime’s own spec for the security context, the API server for admission, the EndpointSlice for readiness.

Satisfying the profile and enforcing it are different things, and only one of them is yours to do. Enforcement comes from a label on the namespace, which a chart cannot set for a namespace it does not own, so label it, or the posture above is a convention nothing checks and nothing fails when a future change regresses it:

kubectl label --overwrite namespace ferroehr \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=latest

With the label in place the API server refuses a non-compliant pod outright (Pod Security Admission), which is a stronger guarantee than any check the chart can make about itself. The install notes print this as a prerequisite for the same reason.

enableServiceLinks: false is load-bearing, not hygiene. The kubelet injects a set of Service link environment variables for every Service in the namespace, and for a Service named ferroehr* those land inside the server’s reserved FERROEHR_ namespace, whose strict boot-time sweep rejects unknown variables and refuses to start. Leaving service links on makes every install crash-loop.

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. In the default posture the server’s only outbound traffic is DNS and PostgreSQL, so those two rules suffice; each integration you switch on adds a target, and a blocked one can fail silently. The full destination table is in Namespaces, network & policy.

Two limits worth stating plainly. First, with networkPolicy.ingressFrom empty the rendered ingress rule carries no from selector, and a rule without from admits every source, other namespaces included. Only the port list is narrowed in that state, so set ingressFrom to your ingress controller for a PHI workload, and set networkPolicy.ingressAllowAll: false if an open ingress rule must never render at all; the full treatment is §Ingress. The viewer’s own policy carries the same pair under viewer.networkPolicy. Second, a NetworkPolicy is only as real as the CNI that implements it: on a cluster whose network plugin does not enforce NetworkPolicy the object is documentation rather than a control, and nothing in Kubernetes reports that. Confirm it by attempting a connection the policy should refuse.

The database-side controls (the server certificate behind sslmode=verify-full, pgaudit, at-rest encryption, WAL archiving / PITR) belong to whoever provisions PostgreSQL; the chart mounts the CA (database.caConfigMap) but cannot enforce the rest. See Operations.

TLS at the pod

The production overlay ends TLS at the Ingress and accepts plaintext_listener by name. To close that gap instead, the server terminates TLS itself (config.server.tls), and every client the chart wires to it follows: the viewer and FerroBRIDGE call https://<release>:<service.port>, the kubelet probes over HTTPS, the Service declares appProtocol: https, and a ServiceMonitor that scrapes the API port uses https too.

The clients check the server’s certificate against a CA you name in service.tls.caExistingSecret. The certificate must carry the Service name (the release’s full name, ferroehr for a release called ferroehr) as a DNS SAN, which no public CA issues, so the chart refuses to render a TLS listener beside an enabled viewer, FerroBRIDGE or API-port scrape while that value is empty. Name a Secret that holds the CA certificate alone: every pod and the ServiceMonitor that read it then read no private key, and a ServiceMonitor in another namespace reads its own copy there. A cert-manager Certificate writes the key pair and the CA into one Secret (tls.crt, tls.key, ca.crt); copy ca.crt out of it (trust-manager’s Bundle does this) rather than naming that Secret. The Ingress in front of the TLS listener must reach it over HTTPS too, and how a controller is told so is controller-specific, so the chart refuses to render the Ingress with server TLS on until ingress.backendTlsAnnotations names it:

config:
  deployment_accepts: []          # nothing left to accept for the listener
  server:
    tls:
      certificate_file: /etc/ferroehr-tls/tls.crt
      key_file: /etc/ferroehr-tls/tls.key
      reload_interval_s: 60
extraVolumes:
  - name: server-tls
    secret:
      secretName: ferroehr-server-tls
      defaultMode: 0440
      items:
        - { key: tls.crt, path: tls.crt }
        - { key: tls.key, path: tls.key }
extraVolumeMounts:
  - { name: server-tls, mountPath: /etc/ferroehr-tls, readOnly: true }
service:
  tls:
    caExistingSecret: ferroehr-server-ca    # ca.crt alone
ingress:
  backendTlsAnnotations:                    # ingress-nginx
    nginx.ingress.kubernetes.io/backend-protocol: HTTPS

When cert-manager renews the certificate, the kubelet updates the mounted files and the server serves the new pair within config.server.tls.reload_interval_s (60 seconds above; unset, only SIGHUP reads the files again), with no restart; a renewed pair that fails to load is logged and the old one stays in service. A rotated client CA (client_ca_file, for client_auth) is picked up on the same interval, and the connections already open keep the trust anchor they were verified against.

The viewer reads the mounted CA through its own cdr.tls.trust_roots_file, beside the image’s roots. FerroBRIDGE reads it through SSL_CERT_DIR, which the chart sets to the image’s system roots plus the mounted CA, so its other peers that verify through the platform verifier stay trusted. For an OIDC issuer whose certificate comes from a private CA, name a Secret holding that CA in viewer.auth.oidc.caExistingSecret (key viewer.auth.oidc.caExistingSecretKey, ca.crt by default): the chart mounts it and points the viewer’s [auth.oidc.tls] trust_roots_file at it. For the server’s own [auth] issuer behind a private CA, name the Secret in auth.caExistingSecret (key auth.caExistingSecretKey, ca.crt by default): the chart mounts it and points the server’s [auth] ca_file at it. The ServiceMonitor reads the Secret from its own namespace, and the prometheus.io/scheme: https pod annotation follows the listener when metrics.enabled is set. With config.server.tls.client_auth: required the kubelet probes cannot present a client certificate, so the chart refuses to render it; keep it at none or optional. config.management.port stays plain HTTP on its own listener.

Health probes

All three 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 or degraded) or 503 (down, booting or draining): checks the database ping, migrations applied, the audit sender and the event outbox, each bounded at one second
startup/health/livenessthe same constant, with a long failure threshold, so a slow first boot is not killed mid-migration

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).

Note

There is no exec probe option. One existed and was removed: it ran the binary’s healthcheck subcommand, which then asked the openEHR status document rather than a health route, so readiness never touched the database and a pod with a dead database reported Ready and took clinical traffic. The subcommand now asks /health/readiness, and the httpGet probes above remain what the chart runs.

Warning

Migrations run only at boot, so a replaced or wiped database leaves the pods NotReady until something migrates it. Readiness reports the migrations indicator down (its detail on /management/health: core schema tables missing (migrations not applied)) while db reads up: the pod reaches PostgreSQL, finds no schema, and does not migrate again, because migration is a startup step. Liveness keeps passing (correctly: the process is healthy), so the kubelet never restarts the container and the Deployment sits with no ready replicas and no error in the logs after the first one:

kubectl -n ferroehr get pods                      # Running, not READY
curl -s http://ferroehr:8080/health/readiness     # 503, "migrations" down, "db" up
kubectl -n ferroehr rollout restart deploy/ferroehr

The check re-tests the schema on every probe, so a pod recovers on its own within one probes.readiness.periodSeconds of the schema existing; an untouched pod returns to ready, with no restart, once anything else has migrated. What needs the restart is the case where the only thing that would migrate is the pod itself.

That is what makes the interaction with flow (b) load-bearing: when migrations run out of band, the migration step must complete before the Deployment rolls, or the first pods sit unready waiting for a schema. migrations.job.enabled does exactly that, as a hook Helm waits on. Durable storage is the other half: an emptyDir-backed or otherwise disposable PostgreSQL puts a clinical repository one node eviction away from this state.

One recovery path does not self-heal, and it is worth knowing before you try it: restoring or dropping part of the schema set. A restore that brings back some of a domain’s relations without its migration bookkeeping makes the next migration run fail permanently with relation "version" already exists: the pod crash-loops, and restarting it retries the same failure. Recreate the whole database rather than one schema.

The management surface is independent of the probes and stays ops-only (/management/info, /prometheus, /metrics, /env, /loggers, /flamegraph). Unlike the bare binary, the chart ships config.management.enabled: true with every endpoint off, so nothing is exposed until you opt one in. Set config.management.endpoints.prometheus: public plus metrics.enabled=true to add the prometheus.io/* scrape annotations; set config.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 of all of this, and the install notes warn you if you add the scrape annotations while the endpoint is still off.

Those annotations are honoured by a Prometheus that discovers targets in its own scrape configuration. An operator-managed Prometheus (kube-prometheus-stack) ignores them and selects targets through ServiceMonitor objects instead, so set metrics.serviceMonitor.enabled=true there; metrics.serviceMonitor.labels is where the label your Prometheus’ serviceMonitorSelector matches on goes. It needs the monitoring.coreos.com CRDs installed first, or the install fails on an unknown kind.

Authentication is required, and the chart says so before it installs

config.auth.enabled defaults to true, and the server requires at least one mechanism with it: a 401 challenge has to name a scheme the server actually implements (RFC 9110 §11.6.1), so it exits rather than run as an openEHR API that can only refuse every request.

The chart therefore refuses to render a values file that enables authentication without configuring one, so helm install stops with an actionable error instead of reporting success and crash-looping:

Error: execution error at (ferroehr/templates/deployment.yaml:...):
config.auth.enabled is true but no authentication mechanism is configured...

Pick one:

config:
  auth:
    issuer: https://keycloak.example.org/realms/ferroehr
    audience: [ferroehr]

or Basic auth, whose password hashes go in secrets.basicUserPasswordHashes while the user, its scopes and its roles stay under config.auth.basic.users.

Warning

config.auth.enabled: false makes the chart render, and serves every request unauthenticated. On a repository holding patient data that is a development-only choice; the chart makes you state it explicitly rather than reaching it by omission.

Integrations

Any server setting is reachable

The chart renders its config tree verbatim into ferroehr.toml. So config.<the.toml.path> sets any key in the configuration reference, including keys this page and values.yaml never mention. There is no allow-list to extend and no chart release to wait for:

# values.yaml — [query] plan_cache_capacity, which values.yaml never names
config:
  query:
    plan_cache_capacity: 512
helm upgrade ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
  --version 21.0.0-rc3 -n ferroehr --reuse-values \
  --set config.query.plan_cache_capacity=512

Turning something off is the same edit in reverse: remove the key (or set the integration’s enabled back to false) and upgrade. The tables below are a curated starting point for the switches most deployments want; they are not the boundary of what the chart supports.

Two things make this safe to rely on:

  • A typo is a boot refusal, not a silent default. The server sweeps its configuration strictly and rejects an unknown key with a did-you-mean, so a misspelled path fails loudly at startup instead of quietly doing nothing. Check before you deploy with the schema and boot gates described under Check your values before you deploy them.
  • Credentials do not belong in this tree. config becomes a mounted file; secrets have their own routes (secrets.*, existingSecret, and the *_file key siblings); see Secrets and mounted config.

The common switches

Every switch below lives in the chart’s config tree, so its key is the TOML key from the configuration reference. Most are off by default; the ones that ship on are marked, and enabling any of the others is an explicit, auditable decision:

IntegrationChart keyDefaultNotes
Specification generationconfig.spec_profiledevelopmentOne coupled choice; stable runs the released generations.
ADMIN APIconfig.admin.enabledoffPhysical, irreversible delete. Gate behind admin RBAC.
Terminology extension APIconfig.terminology.api_enabledoff404 when off.
Event-subscription APIconfig.events.admin_apioffAdmin CRUD over event filters.
Bearer tokensconfig.auth.issuerunsetWith config.auth.audience; the issuer’s metadata and key set are read at boot, asymmetric algorithms only.
RBACconfig.authz.rbac.enabledonThe coarse role gate (active while config.auth.enabled).
ABACconfig.authz.abac.enabledoffCedar (policies via a config.files mount) or a remote policy decision point.
Eventing → AMQPconfig.events.enabledoffEnvelopes are PHI-free by design. Use config.events.tls: true; URL via secrets.eventsUrl.
S3 multimediaconfig.multimedia.enabledoff⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket; keys via secrets.multimediaAccessKeyId and secrets.multimediaSecretAccessKey.
Terminology serverconfig.terminology.server.enabledoffThe one FHIR terminology server, at config.terminology.server.url. To run FerroTERM in the cluster instead, use terminology.enabled (FerroTERM), which sets these keys for you.
ATNA audit trailconfig.audit.enabledonOn with the local store only; forwarding (config.audit.syslog, config.audit.fhir_feed) is opt-in per sink.
Version signingconfig.signing.enabledon (config.signing.mode: digest)pgp mode needs a config.files key plus secrets.signingKeyPassphrase, and fails closed at boot without a usable key.
OTLP telemetryconfig.telemetry.otlp.endpointunsetSetting the endpoint is all it takes; without a config.telemetry.otlp table nothing is exported. With networkPolicy.egress.enabled, add a rule for the collector, since a blocked exporter drops spans without an error.

Full detail on each is in Beyond the core, Security, and Operations.

The usage report is on by default, and usageReport.enabled=false switches it off for FerroEHR, for FerroTERM and for FerroBRIDGE. Each of those takes its own terminology.usageReport.enabled or bridge.usageReport.enabled when you want it to differ. FerroPIX runs from its own chart, which cannot read this chart’s switch: set ferropix.usageReport.enabled=false as well, or the chart refuses to render.

FerroEHR Viewer (a second workload, off by default)

viewer.enabled renders a second Deployment and Service for the viewer beside the CDR, from its own image (viewer.image.repository, tagged appVersion by default so the two move together). It is off by default: the viewer is a separate product surface with its own attack surface, and a CDR is complete without it.

Three properties are worth knowing before you switch it on:

  • It reaches the CDR strictly over the REST API, which viewer.networkPolicy.enabled (on by default) enforces rather than assumes: the viewer’s egress admits the CDR Service, DNS and outbound HTTPS for an identity provider, and nothing else. It holds no database credential.
  • It is a human-facing web UI, so viewer.ingress.enabled is the normal way to reach it, and viewer.auth.oidc.enabled with an issuer, client id and viewer.auth.oidc.publicBaseUrl is how you keep a person who should not see PHI out of it. Its client secret comes from viewer.existingSecret, mounted as a file exactly as the server’s DSN is.
  • viewer.replicaCount is 1 deliberately. The viewer holds session state in process, so a second replica needs sticky sessions at the ingress or users are logged out on a reroute.

The viewer carries the same security context as the server, and the chart’s render gate holds it to the same Restricted profile and the same pod-isolation settings: a release whose two workloads disagreed about hostUsers would be a posture nobody could state in one sentence. The deployment probe harness then reads that back off the running viewer container, exactly as it does for the server: its uid, its empty capability bounding set, its read-only root and its seccomp filter come from the container runtime’s own spec, its admission from an enforce=restricted namespace, and its login page from an HTTP request made inside the cluster through the viewer Service, so the second workload is never vouched for by the first. What that run does not establish is stated in its own record: the viewer’s OIDC path, the screens behind a session, and whether a CNI enforces the viewer’s NetworkPolicy. Its screens are documented in the viewer chapter.

FerroPIX (the patient identity source, on by default)

The CDR keeps no patient-to-record cross-reference of its own by default: it asks an IHE patient identity source over PIXm (ITI-83) which pseudonym the clinical domain holds for a patient ([ehr_index]). The chart takes FerroPIX as the chart dependency ferropix (oci://ghcr.io/ferrohealth/charts, pinned in Chart.lock), so its Deployment, Service, NetworkPolicy and configuration are its own chart’s, and every key under ferropix: is that chart’s value. With ferropix.enabled (the default) the chart writes config.ehr_index.provider = "ihe" and config.ehr_index.pixm_url at the FerroPIX Service, and refuses a values file that sets either too. With the egress policy on, it adds the rule to FerroPIX’s pods.

You supply FerroPIX’s PostgreSQL URL and sealing key, its issuer, the pseudonymisation domain whose consumer is the CDR’s client, and the CDR’s side of the same facts:

ferropix:
  database: {existingSecret: ferropix-db}
  dataKey: {existingSecret: ferropix-data-key}
  config:
    public_base_url: https://pix.example.org
    auth:
      audience: [https://pix.example.org/fhir]
      issuer: [{issuer: https://idp.example.org/realms/pix}]
    trust:
      issuer: [{issuer: https://idp.example.org/realms/pix, clients: [hospital-source]}]
    pseudonym_domain:
      - {namespace: urn:oid:2.999.7.1, name: Clinical domain, consumer_client_id: ferroehr}
config:
  privacy:
    subject_namespaces: [urn:oid:2.999.7.1]
  ehr_index:
    assigning_authority: urn:oid:2.999.7.1
    oauth:
      token_endpoint: https://idp.example.org/realms/pix/protocol/openid-connect/token
      client_id: ferroehr
      scope: system/Patient.rs
secrets:
  ehrIndexClientSecret: <the client secret>

secrets.ehrIndexClientSecret is mounted at /etc/ferroehr-secrets/ehr_index.oauth.client_secret, and the chart injects the matching client_secret_file; the production overlay reads it from a Secret you own through extraEnv instead. Another PIX Manager: ferropix.enabled: false and config.ehr_index.pixm_url. A CDR run on its own: ferropix.enabled: false and config.ehr_index.provider: local.

The chart pins FerroPIX 0.0.12 by digest (ferropix.image) with the FerroPIX chart built for it. Since FerroPIX 0.0.8, public_base_url is a top-level key and the clients that may act as a Patient Identity Source sit in the [[trust.issuer]] table, apart from the [[auth.issuer]] entry that names the issuer; FerroPIX refuses the older server.public_base_url and auth.issuer[].clients, so an upgrade from a chart that pinned FerroPIX 0.0.7 moves those two keys in your values.

The restricted-profile gate holds FerroPIX’s pods to the Pod Security Standards restricted profile like every other pod in the release. FerroPIX’s chart sets the two properties this chart adds for its own pods itself: enableServiceLinks: false, and a user namespace per pod unless FerroPIX’s own hostUsers value, under ferropix:, is true.

FerroTERM (a terminology server beside the CDR, off by default)

FerroTERM is the Ferro family’s FHIR terminology server: R4, R4B and R5 endpoints over a precomputed index, no JVM, no database, one distroless image. terminology.enabled renders it as a third workload beside the CDR and the viewer, and points the CDR at it, so archetype value-set bindings resolve at commit and AQL TERMINOLOGY() expands through it. The CDR’s own /terminology/* extension routes are a separate switch: they answer only with config.terminology.api_enabled=true, which is false by default, and return 404 otherwise whether or not FerroTERM is running. It is the Helm equivalent of the compose terminology overlay.

helm upgrade --install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
  --version 21.0.0-rc3 -n ferroehr --reuse-values \
  --set terminology.enabled=true

That renders a Deployment, a ClusterIP Service, its own ServiceAccount, a ConfigMap holding the code systems, and a NetworkPolicy. It also writes three things into the CDR’s ferroehr.toml: config.terminology.server.enabled, config.terminology.server.fail_on_error from terminology.failOnError, and config.terminology.server.url at the Service address.

Because the chart writes those three, a values file that contradicts any of them is refused at render rather than silently overwritten. Three shapes are refused while terminology.wireCdr is on:

  • config.terminology.server.url — two answers to “which server resolves a binding” is how resolution ends up pointed somewhere nobody chose.
  • config.terminology.server.enabled: false — the rendered ferroehr.toml would say the opposite of your values file.
  • config.terminology.server.fail_on_error disagreeing with terminology.failOnError — same reason, for the posture that decides whether an unresolvable binding is a 422.

Set terminology.wireCdr=false to keep your own server and run FerroTERM unwired. The CDR asks one terminology server, so a server of your own and the in-cluster FerroTERM are an either-or.

No Ingress is rendered, and no value renders one. The CDR is the only caller, which is the posture the hosted sandbox runs and what the SNOMED CT Affiliate Licence asks of a public deployment: clause 2.2.4 permits a public system to serve SNOMED-encoded data provided users cannot extract a substantial portion of the release and no fee is charged, and clause 2.7 requires measures so the release cannot be downloaded except by authorised users. A published FHIR terminology endpoint answers $expand and $lookup to every client that reaches it, and FerroTERM has neither authentication nor a rate limit of its own. Its NetworkPolicy is therefore the one in this chart that narrows sources by default: ingress admits the CDR’s pods on the terminology port and nothing else, egress admits DNS. terminology.networkPolicy.extraIngressFrom adds a peer you decide to admit. No boolean opens this port the way networkPolicy.ingressAllowAll opens the CDR’s — and a peer you add is admitted exactly as written, which includes admitting everything: a namespaceSelector: {} selects every namespace in the cluster (networking/v1: “if present but empty, it selects all namespaces”). The values schema therefore refuses an empty peer, and an empty podSelector or namespaceSelector inside one, so reaching that state takes labels you chose rather than a blank.

Out of the box it serves the shaped seed, which is synthetic content under the reserved example.test domain: two code systems and two value sets, the same content the conformance lane binds to. The chart carries those files and mounts them as a ConfigMap, so a fresh install from the registry serves terminology with no repository checkout. terminology.codeSystems.existingConfigMap swaps in a ConfigMap of your own FHIR JSON. No licensed terminology ships with FerroEHR or FerroTERM. One caveat with a ConfigMap of your own: the pod’s rollout annotation hashes the chart’s own seed files and the NAME you gave, not the contents of an object the chart does not own, so editing that ConfigMap updates the volume while the running pods keep serving what they read at boot. Restart the workload after such an edit (kubectl rollout restart deployment/ferroehr-terminology).

A real release is an index you build off-cluster. Run ferroterm-build --rf2 <release.zip> --out <dir> on a machine that holds the archive, put the output on a PersistentVolume, and name that claim in terminology.index.persistentVolumeClaim. The chart mounts it read-only at terminology.index.mountPath and sets FERROTERM_INDEX; with no claim named, the variable is absent and the seed is served alone. The chart provisions no storage and renders no build Job, for the same reason it provisions no database: unpacking a licensed archive is a step whose licence terms are yours, not a step a chart should start on its own.

Naming a claim also switches this Deployment to strategy: Recreate. A rolling update creates the replacement pod before the outgoing one goes away, and a ReadWriteOnce volume can only be attached by one node, so the new pod would sit Pending on the attachment while kubectl reported a progressing rollout. Recreate stops the old pods first, which costs a short terminology outage at every upgrade and finishes. If your index volume is ReadOnlyMany, override it with terminology.strategy={type: RollingUpdate}. FerroTERM’s loading page covers the other code systems and their build flags. Size the memory limit for the edition before you mount it: terminology.resources ships a 1536Mi limit, and the same FerroTERM page states the resident size of each edition, which the limit must cover.

The fail posture is the CDR’s shipped one. terminology.failOnError defaults to false, so a binding the server cannot resolve is accepted; true turns an unreachable terminology server into a 422 refusal. The terminology page explains both. A code the server resolves as outside its value set is refused with 422 under either posture.

FerroTERM’s licence. BUSL-1.1 from the same Licensor as FerroEHR: non-commercial production use is free, any other production use needs a commercial licence. Its README says which is which. The licence for whatever code systems you load is separate and yours to hold; SNOMED CT needs an Affiliate Licence (free in Member countries, see snomed.org/get-snomed), and every surface showing its content carries the notice clause 8.3.1 prescribes plus the release version and date.

More than one replica needs two other keys. terminology.replicaCount is a throughput choice — each replica loads its own copy of the index, so watch the memory limit — and above 1 the pods spread one per node by default, the same soft constraint the CDR gets. Turn terminology.podDisruptionBudget.enabled on with it: without a budget a node drain may take every terminology replica at once, and the CDR’s own budget does not cover these pods. It is off by default because the shipped replica count is 1 and a budget over a single pod blocks the drain it exists to survive.

FerroTERM carries the same security context as the CDR and the viewer, and the chart’s render gate holds all three to the same Restricted profile. The deployment probe harness reads this workload back off a running cluster too: its applied runtime posture, the shaped seed reaching the process from the ConfigMap volume, a refusal on the terminology port from a pod the policy does not name, and the CDR resolving a bound code through the Service. Its image is pinned by digest in terminology.image.digest and moves on FerroTERM’s own release line, so it does not follow appVersion the way the viewer image does. Because that digest is set, terminology.image.tag alone deploys nothing: the digest wins, and a tag pointing anywhere else is refused at render rather than ignored. Clear the digest to deploy by tag.

FerroBRIDGE (the FHIR bridge, off by default)

FerroBRIDGE maps between FHIR R4 and openEHR with FHIRconnect mappings and reaches FerroEHR over ITS-REST. FerroEHR itself serves no FHIR API for clinical data, so FerroBRIDGE is the component that does. bridge.enabled renders it as its own workload in the same release: a Deployment, a ClusterIP Service, its own ServiceAccount, a ConfigMap holding its configuration file, and a NetworkPolicy.

The chart writes three sections of that file for you:

  • [cdr]: base_url is this release’s CDR Service with its REST base path (http://<release>:<service.port><config.rest.base_path>), and [cdr.credentials] names a file mounted from your Secret.
  • [terminology]: with terminology.enabled on, base_url is the chart’s FerroTERM Service at /r4 and wire_version is r4. With it off the section is absent, and you may set your own under bridge.config.
  • [mappings]: directory is where the mapping files below are mounted.

The CDR credential comes from a Secret you create. The chart never takes it as a value. The Secret carries the keys user and password, for a user of the CDR’s config.auth.basic.users whose scopes grant what FerroBRIDGE calls. The password is mounted as a file and named by its _file key, so it is in neither the ConfigMap nor the environment. FerroBRIDGE does not treat the username as a secret, and the chart passes it as FERROBRIDGE__CDR__CREDENTIALS__USER. FerroBRIDGE reads an environment value as TOML when it parses as TOML, so a username made only of digits arrives as a number and is refused at boot. A static bearer token is no longer a choice: machine-to-machine authentication is HTTP Basic or the OAuth 2.0 client credentials grant.

kubectl -n ferroehr create secret generic ferroehr-bridge-cdr \
  --from-literal=user=bridge --from-file=password=./bridge-password

The mapping files come from a ConfigMap you create, named in bridge.mappings.existingConfigMap, with each key listed in bridge.mappings.files. The chart mounts every key on its own: a whole ConfigMap volume also carries the kubelet’s ..data snapshot directories, and FerroBRIDGE 0.0.5 reads those as a second and third copy of every file and refuses the start with a duplicate mapping name. FerroBRIDGE fetches each template a mapping context names from the CDR at boot, so upload the templates first; a template the CDR does not hold stops the start. Without a mapping set FerroBRIDGE still starts, and $tofhir and $toopenehr answer 503.

bridge:
  enabled: true
  cdr:
    existingSecret: ferroehr-bridge-cdr
  mappings:
    existingConfigMap: ferroehr-bridge-mappings
    files:
      - vital_signs.context.yml
      - blood_pressure.yml
  networkPolicy:
    ingressFrom:
      - podSelector:
          matchLabels:
            app.kubernetes.io/name: my-fhir-client

It stays inside the cluster until FerroBRIDGE authenticates its callers. FerroBRIDGE has no inbound authentication yet (FerroBRIDGE#400), and whoever reaches its port acts on the CDR with the credential above. So the chart renders no Ingress for it and no value renders one, the Service is always ClusterIP, and the NetworkPolicy admits only the peers you list in bridge.networkPolicy.ingressFrom. An empty list is refused at render, and the values schema refuses a peer that selects everything (an empty peer, or an empty podSelector or namespaceSelector inside one). There is no key that admits every source. Egress admits this release’s CDR, FerroTERM when it runs, and DNS; bridge.networkPolicy.extraEgress adds a rule for an upstream you name in bridge.config. When you narrow the CDR’s own networkPolicy.ingressFrom, the chart adds FerroBRIDGE’s pods to it, and FerroTERM’s policy admits them too.

Anything else goes in bridge.config, rendered into the same file with the keys of FerroBRIDGE’s configuration reference. FerroBRIDGE refuses an unknown key at boot. The render refuses a key the chart writes (cdr.base_url, cdr.credentials, server.listen, terminology while FerroTERM runs, mappings.directory while the chart mounts the mappings) and any secret-shaped key, such as a password, a token or cdm.url: set its _file sibling and mount the file with bridge.extraVolumes and bridge.extraVolumeMounts. FerroBRIDGE’s FHIR facade ([facade]) keeps its identity map in a file it writes, and the root filesystem is read-only, so turning the facade on also takes a PersistentVolumeClaim of yours mounted with those two keys and named in facade.identity_store, with bridge.strategy={type: Recreate} when the claim is ReadWriteOnce. Back that volume up with the CDR’s database.

Probes use FerroBRIDGE’s own endpoints: liveness /health/liveness, readiness /health/readiness, which answers 503 while the CDR or FerroTERM is unreachable. FerroBRIDGE carries the same security context as the other workloads and the release-wide hostUsers setting, and the render gate holds it to the same Restricted profile. Its image is pinned by digest in bridge.image.digest and moves on FerroBRIDGE’s own release line, never with appVersion; a tag other than the pin is refused while the digest is set. The deployment probe harness boots it on a cluster and reads back its runtime posture, its admission, the credential being used for the template fetch, a refusal on its port from a pod outside ingressFrom, and its readiness from a pod inside it. FerroBRIDGE’s own deployment documentation is at https://ferrobridge.eu/docs/operate/container.html.

Per-domain backups (one CronJob per domain, off by default)

backup.enabled renders one CronJob per pseudonymisation domain: backup.clinical.schedule dumps the clinical schemas, backup.party.schedule dumps the identities, backup.linkage.schedule dumps the party-to-EHR map, and each writes to its own existing claim (backup.clinical.persistentVolumeClaim, backup.party.persistentVolumeClaim, backup.linkage.persistentVolumeClaim). The image is backup.image.repository, which carries pg_dump.

A fourth job renders for the audit domain when database.audit.existingSecret gives it a database of its own. While the trail shares the clinical database the clinical dump names its schema and nothing more is needed; once it is somewhere else, that dump cannot reach it and nothing else would take it, so backup.audit.schedule, backup.audit.existingSecret and backup.audit.persistentVolumeClaim become required the same way the other three are, and the clinical dump stops naming the audit schema.

The chart refuses to render rather than let the separation collapse quietly: a missing claim is an error naming the value, and naming the same claim for any two domains is an error too, because one volume holding two of them — the clinical record, the identities, or the map that joins them — is the state per-domain backups exist to prevent.

Each job needs its own backup credential, named by backup.clinical.existingSecret, backup.party.existingSecret, backup.linkage.existingSecret and, when it renders, backup.audit.existingSecret, and the render is refused without them. It cannot be the pool’s credential: each domain’s runtime role is revoked from the other domains, so a dump taken through one would be silently partial. Give each domain a read-only role on that domain’s schema alone. Every job reads its DSN from a mounted file, never an environment variable.

The chart provisions no storage. Create the claims yourself, and give them different access control; that part no chart can do for you. The restore procedure, and the ferroehr db verify gate that judges it, are on the Operating page.

Staying available while things move

Five defaults keep the API serving through the events that routinely interrupt it. None needs configuring; each is listed because the reason matters when you tune it.

Replicas land on different nodes. With nothing telling the scheduler otherwise, two replicas can share one node and one node failure is a total outage. The chart ships a soft spread constraint (maxSkew: 1 over kubernetes.io/hostname, whenUnsatisfiable: ScheduleAnyway) so replicas prefer separate nodes but a single-node or full cluster still schedules them rather than leaving a pod Pending. Setting topologySpreadConstraints replaces it wholesale, so give the complete constraint including its own labelSelector; add a topology.kubernetes.io/zone entry if your cluster spans zones.

A terminating pod stops receiving requests before it shuts down. Deleting a pod removes it from the EndpointSlice and sends SIGTERM concurrently, and the removal still has to propagate to every node. preStopSleepSeconds (default 5) holds the container for that window first. It uses the native sleep hook action rather than an exec hook, because the image ships no shell to run one.

A node drain does not hang on unhealthy pods. The PodDisruptionBudget sets podDisruptionBudget.unhealthyPodEvictionPolicy: AlwaysAllow, the documented recommendation. The API default, IfHealthyBudget, makes a drain wait for pods to become healthy, which never completes when they are unhealthy because of the drain.

A migration interrupted by a drain is not counted as a failure. The migration Job carries a podFailurePolicy that ignores pod failures caused by disruption, so ordinary cluster maintenance during a release cannot exhaust migrations.job.backoffLimit and fail the upgrade with no migration error anywhere in the logs.

A reused connection is not closed under a client. The server closes a kept-alive connection only after config.server.idle_timeout_ms (120 s by default) with no request, and counts config.server.header_read_timeout_ms from the first byte of each request. Keep the idle limit longer than the idle timeout of your clients (90 s for a Go client) and of the ingress controller or load balancer in front, so they close an idle connection first. A client that sends a request into a connection the server is closing gets no answer, and a client that never retries a POST reports the commit as failed.

Two more are available and off by default. service.trafficDistribution: PreferSameZone keeps traffic inside the caller’s zone, for lower latency and inter-zone cost, at the price of even load, so measure before setting it. And autoscaling.behavior passes scaling policies straight through; the documented defaults already scale up immediately and wait out a five-minute stabilization window before scaling down, so change it only to be more conservative.

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 at two or more (or autoscaling.enabled) 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.

A change anywhere under config, config.files or secrets changes the checksum/config pod annotation, so helm upgrade rolls the pods for a configuration-only change too, including a rotated secret or an edited ABAC policy, both of which are read at boot and would otherwise reach the volume while every running pod kept using the old value.

helm uninstall removes everything the chart created; the chart declares no PersistentVolumeClaim, so nothing is left behind; your database, and the Secret holding its DSN, are yours and survive.

Preview an upgrade against what you have installed with helm diff, or render the new chart version and read it:

helm template ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 \
  -n ferroehr -f my-values.yaml | less

Working from a checkout instead, deploy/helm/validate.sh runs the chart’s full render gate: the helm-version pin, lint, YAML validity, the structural Restricted-profile and selector-immutability gates, the secret-leak gate, the values-schema probes, and the golden-render diff. It is the same gate CI runs on every change to the chart, so a local run and a pull request agree by construction.

Check your values before you deploy them

A render that lints is not a deployment that boots. validate.sh never runs the server, so it cannot see a configuration the server refuses: a missing authentication mechanism, an HS* algorithm, a password hash that is not a real Argon2id PHC string, SMART enabled without its public base URL. Every one of those renders perfectly and crash-loops the pod. The script prints exactly which properties it does not check, on success as well as on failure, so a green run is not mistaken for a working deployment.

The check that closes that gap runs the image against your rendered configuration:

FERROEHR_IMAGE=ghcr.io/ferrohealth/ferroehr:5.6.0-rc3 \
  deploy/helm/ci/boot-check.sh my-values.yaml

It renders the chart, mounts the ConfigMap and the Secret exactly as the Deployment does (with their real values, not placeholders) replays the declared environment, and runs ferroehr config check inside the image. Point FERROEHR_IMAGE at the tag you intend to deploy: the answer is specific to that image, since a key your values carry may be newer than the server that has to read it.

It validates configuration only; it opens no socket, so it cannot tell you the issuer resolves, the broker is reachable or the database accepts the DSN. CI runs the same script over every values overlay the chart ships.