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.
- Installing
- Configuration: one key, one file
- Database roles — who runs migrations
- Secrets and mounted config
- Security posture
- Health probes
- Authentication is required, and the chart says so before it installs
- Integrations
- Any server setting is reachable
- The common switches
- FerroEHR Viewer (a second workload, off by default)
- FerroPIX (the patient identity source, on by default)
- FerroTERM (a terminology server beside the CDR, off by default)
- FerroBRIDGE (the FHIR bridge, off by default)
- Per-domain backups (one CronJob per domain, off by default)
- Staying available while things move
- 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. SethostUsers: trueto 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 adddoes not work for this chart, and never will. There is no HTTP chart repository and noindex.yaml: OCI is the only publication path, so that there is exactly one place a chart version can exist. Every command takes theoci://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.
| Selects | Pin with | Line | |
|---|---|---|---|
| Chart version | templates, values schema, defaults | --version 21.0.0-rc3 | SemVer over the chart’s own contract |
| Image tag | the 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 pushover an existing one would silently replace it. That is why a correction always ships as a new chart version, and why the6.0.3→6.0.4bump exists: the chart’sappVersionmoved 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 Foundit 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 the3.17.4cycle, so image tags from before it have nothing to verify.
Note
helm install --verifyandhelm verifydo not apply: they check a PGP.provfile, and this chart ships none. That is deliberate: a.provneeds 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
configdefaults track development and can be ahead ofappVersion’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 checkExit 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
nullfor this deployment. A published chart is checked this way before it is published: the publish lane refuses to ship a chart whose defaults its ownappVersionimage rejects, and it repeats the check against every values overlay the chart carries, so this matters mainly when you install from a checkout ofmain, 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 namessandboxorproduction, 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).sandboxmust 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 onGET /ferroehr/rest/status. A deployment holding patient data setsproduction, which refuses to start while a separation is missing and not named inconfig.deployment_accepts. The chart defaults leave several open: one DSN for every domain, an emptyconfig.privacy.subject_namespaces, the server’sopendefaults forconfig.audit.fail_modeandconfig.authz.rbac.ehr_access_default,config.db.migrate: applyon the runtime credential, and a plaintext listener behind the ingress; seedeployment_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 ofconfig.audit.store.retention_dayswhen the horizon must be exact in calendar years, such as3for a German SGB V § 309 controller withconfig.audit.store.sgb_v_309_controller: true.config.audit.categories: an emptytemplatesandarchetypesmap, under which the category map FerroEHR ships applies: it classifies the published CKM templates and archetypes of one priority category and records anything else asunclassified. 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:
| Gap | In the overlay |
|---|---|
shared_credential | the 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_cluster | yours 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_credential | config.db.migrate: verify, with migrations.job.enabled preparing the schema on database.migrateExistingSecret |
open_subject_namespace | config.privacy.subject_namespaces declared |
audit_off, audit_fails_open | the audit trail on with its local store, config.audit.fail_mode: closed |
no_category_map | the shipped category map (the default), or config.audit.categories templates and archetypes of your own |
open_ehr_access_default | config.authz.rbac.ehr_access_default: restricted |
auth_off | OIDC authentication on |
rbac_off | the role gate on (config.authz.rbac.enabled: true, the chart’s default) |
plaintext_broker | no AMQP integration on |
plaintext_audit_feed, plaintext_object_store | the audit FHIR feed and the multimedia store off; an https:// URL for either when you turn it on |
plaintext_listener | accepted 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:
| Role | Purpose |
|---|---|
| owner | owns the database (provisioning only) |
ferroehr_migrator | runs the append-only schema migrations |
ferroehr_clinical | day-to-day reads and writes; the running pod connects as this |
ferroehr_clinical_reader | read-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 offerroehr_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 needsdatabase.migrateExistingSecretas well, for the reason the paragraph further down gives:verifyreads 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.
| Secret | How the chart delivers it |
|---|---|
| the database DSN | mounted 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.signingKeyPassphrase | mounted at /etc/ferroehr-secrets/signing.key_passphrase |
secrets.multimediaSecretAccessKey | mounted 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.terminologyClientSecret | mounted 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.eventsUrl | mounted at /etc/ferroehr-secrets/events.url |
secrets.auditFhirFeedUrl | env; audit.fhir_feed.url is the only credential-bearing key with no *_file sibling |
secrets.multimediaAccessKeyId | env; 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.basicUserPasswordHashesunder the user-id, declare only theuser, itsscopesand itsrolesunderconfig.auth.basic.users. Settingpassword_hashunderconfig: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.
| 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) |
hostUsers | false; the pod gets its own user namespace |
supplementalGroupsPolicy | Strict; only the groups the manifest names |
| ServiceAccount token | not mounted (the workload never calls the K8s API) |
enableServiceLinks | false (see below; not a preference) |
| NetworkPolicy | ports 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:
| Probe | Route | Contract |
|---|---|---|
| liveness | /health/liveness | 200 while the process is up; touches no dependency |
| readiness | /health/readiness | 200 (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/liveness | the 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
execprobe option. One existed and was removed: it ran the binary’shealthchecksubcommand, 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 thehttpGetprobes 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
migrationsindicatordown(its detail on/management/health:core schema tables missing (migrations not applied)) whiledbreadsup: 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/ferroehrThe check re-tests the schema on every probe, so a pod recovers on its own within one
probes.readiness.periodSecondsof 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.enableddoes exactly that, as a hook Helm waits on. Durable storage is the other half: anemptyDir-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: falsemakes 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.
configbecomes a mounted file; secrets have their own routes (secrets.*,existingSecret, and the*_filekey 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:
| Integration | Chart key | Default | Notes |
|---|---|---|---|
| Specification generation | config.spec_profile | development | One coupled choice; stable runs the released generations. |
| ADMIN API | config.admin.enabled | off | Physical, irreversible delete. Gate behind admin RBAC. |
| Terminology extension API | config.terminology.api_enabled | off | 404 when off. |
| Event-subscription API | config.events.admin_api | off | Admin CRUD over event filters. |
| Bearer tokens | config.auth.issuer | unset | With config.auth.audience; the issuer’s metadata and key set are read at boot, asymmetric algorithms only. |
| RBAC | config.authz.rbac.enabled | on | The coarse role gate (active while config.auth.enabled). |
| ABAC | config.authz.abac.enabled | off | Cedar (policies via a config.files mount) or a remote policy decision point. |
| Eventing → AMQP | config.events.enabled | off | Envelopes are PHI-free by design. Use config.events.tls: true; URL via secrets.eventsUrl. |
| S3 multimedia | config.multimedia.enabled | off | ⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket; keys via secrets.multimediaAccessKeyId and secrets.multimediaSecretAccessKey. |
| Terminology server | config.terminology.server.enabled | off | The 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 trail | config.audit.enabled | on | On with the local store only; forwarding (config.audit.syslog, config.audit.fhir_feed) is opt-in per sink. |
| Version signing | config.signing.enabled | on (config.signing.mode: digest) | pgp mode needs a config.files key plus secrets.signingKeyPassphrase, and fails closed at boot without a usable key. |
| OTLP telemetry | config.telemetry.otlp.endpoint | unset | Setting 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.enabledis the normal way to reach it, andviewer.auth.oidc.enabledwith an issuer, client id andviewer.auth.oidc.publicBaseUrlis how you keep a person who should not see PHI out of it. Its client secret comes fromviewer.existingSecret, mounted as a file exactly as the server’s DSN is. viewer.replicaCountis 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 renderedferroehr.tomlwould say the opposite of your values file.config.terminology.server.fail_on_errordisagreeing withterminology.failOnError— same reason, for the posture that decides whether an unresolvable binding is a422.
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_urlis 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]: withterminology.enabledon,base_urlis the chart’s FerroTERM Service at/r4andwire_versionisr4. With it off the section is absent, and you may set your own underbridge.config.[mappings]:directoryis 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.