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

Configuration reference

FerroEHR is configured by one file — ferroehr.toml — whose sections cover the entire server, with FERROEHR_* environment variables (and repeatable --set flags) as per-key overrides on top. This chapter is the complete reference: the quickstart, how configuration loads and how env names map onto the file, a subsection per configuration area, the CLI tools that validate and scaffold a config, the production checklist, and — for upgraders — the old→new variable map.

Quickstart

Generate an annotated template, edit it, and run:

# Write a fully-commented ferroehr.toml with every key at its default.
ferroehr config default > ferroehr.toml

# Edit it — at minimum set db.url and an auth mechanism (see the checklist below).
$EDITOR ferroehr.toml

# Validate without touching the database, then run.
ferroehr config check --config ferroehr.toml
ferroehr --config ferroehr.toml

A server started with no file and no environment still boots (see Zero-config boot) — the file is optional, and every key has a default.

How configuration loads

Configuration is assembled once at boot from four layers, lowest precedence to highest:

  1. Built-in defaults — the values in the tables below.
  2. The config fileferroehr.toml (see file discovery).
  3. FERROEHR_* environment variables — override individual keys.
  4. --set key=value CLI flags (repeatable) — win over everything.

Two permanent conventional aliases sit below their FERROEHR_ forms within layer 3: DATABASE_URLdb.url and RUST_LOGlog.filter. Nothing else has a non-FERROEHR_ name.

The environment-variable mapping

Every key has one mechanical env spelling: FERROEHR + the TOML path, upper-cased, with a double underscore (__) between every segment — including after the FERROEHR prefix. A single underscore only ever appears inside a key word.

TOMLEnvironment variable
[db] max_connections = 20FERROEHR__DB__MAX_CONNECTIONS=20
[auth.oidc] issuer = "…"FERROEHR__AUTH__OIDC__ISSUER=…
[management.endpoints] env = "off"FERROEHR__MANAGEMENT__ENDPOINTS__ENV=off
[terminology.external.providers.default] url = "…"FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__URL=…

Scalars are typed automatically (bool / int / float, else string). List-typed keys take comma-separated values (FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr,other). Arrays of tables — the Basic-auth user store — are file-only.

Note

Enum values are lowercase / snake_case tokens, exactly as the tables show. Secret-typed keys are redacted everywhere the config is rendered (the /management/env snapshot, ferroehr config check, logs), and each has a *_file sibling that reads the value from a file (for Kubernetes/Docker secret mounts). Setting a secret and its *_file sibling at once is a boot error.

File discovery

The first of these that exists is loaded (later layers still override its values):

  1. --config <path> (fatal if missing/unreadable),
  2. FERROEHR_CONFIG=<path> (fatal if missing/unreadable),
  3. ./ferroehr.toml (current directory),
  4. /etc/ferroehr/ferroehr.toml.

An explicitly pointed-at file (1–2) is fatal if absent; the search-order files (3–4) are simply skipped when absent (but fatal if present and unparseable).

Strict validation

Configuration is validated at boot (and by ferroehr config check), and the server refuses to start on any error:

  • Unknown keys are rejected — in the file (with the offending file:line) and in the FERROEHR_ environment namespace — with a did-you-mean suggestion. A misspelled security key can no longer be silently ignored.
  • Type errors are boot errors, naming the key, the expected type, and where the bad value came from.
  • Semantic errors are aggregated — one pass reports every problem at once, so a broken config is fixed in a single iteration.

[server]

The HTTP listener and REST surface.

[server]
bind = "0.0.0.0:8080"
base_path = "/ferroehr/rest/openehr/v1"
max_in_flight = 256
swagger_ui = true
cors_permissive = false
system_id = "ferroehr.local"
KeyTypeDefaultDescription
bindstring0.0.0.0:8080Socket address the API listener binds.
base_pathstring/ferroehr/rest/openehr/v1ITS-REST base path all API routes hang off. The status, health-adjacent and documentation routes hang off its parent (/ferroehr/rest by default), and the served OpenAPI document describes whatever paths this setting produces — never the defaults.
max_in_flightint256Concurrent-request admission cap (not per second). Requests beyond it are shed immediately with 503 + Retry-After — never queued — so offered load beyond capacity cannot exhaust memory. Status/health/discovery routes are never limited. 0 disables shedding.
swagger_uibooltrueServe Swagger UI + the OpenAPI JSON at the REST root. Consider false in production.
cors_permissiveboolfalsePermissive (development) CORS. Production configures explicit origins.
system_idstringferroehr.localThis deployment’s own openEHR system identifier — see below. Set a stable, deployment-unique name in production (FERROEHR__SERVER__SYSTEM_ID).

system_id: the data-authoring identity

system_id is the identifier this CDR stamps into the data it authors. It appears on the wire in three places:

  • EHR.system_id, recorded when an EHR is created. The openEHR RM (EHR Information Model, EHR Identifier Allocation) says the EHR.system_id “should be set to the value that would normally be used for locally created EHRs” — i.e. a value the deployment chooses, not a product constant.
  • AUDIT_DETAILS.system_id on every commit for which the client did not supply one through the openehr-audit-details header. The openEHR REST API requires that “when system_id is not provided by the client, the server MUST set it to its own configured system identifier”.
  • OBJECT_VERSION_ID.creating_system_id — the middle segment of every version identifier the server mints (<object_id>::<creating_system_id>::<version>).

Practical notes:

  • Choose it before going live and keep it stable. The value is stored with each EHR and each version; changing it later affects only newly authored data — existing EHR ids, audit rows, and version identifiers are never rewritten, and previously issued OBJECT_VERSION_IDs stay valid.
  • Make it unique per system, so data exchanged between openEHR systems keeps unambiguous provenance. A DNS-style host name (cdr.hospital.example) is the common convention.
  • With multi-tenancy on ([tenancy]), a tenant’s own system_id takes precedence over this value for requests resolved to that tenant.
  • Validated at boot. An empty value is refused (the RM requires a non-empty AUDIT_DETAILS.system_id), as is one containing :: — that is the OBJECT_VERSION_ID field separator, so it would make version identifiers unparseable.
  • system_id is not [server.identity]. system_id says which system authored the data; [server.identity] below is the display identity of the OPTIONS System-Options manifest (product, version, vendor, advertised profile). Rebranding changes the manifest and nothing in stored data; changing system_id changes what new data says about its origin and leaves the manifest alone. They are set independently.

[server.tls] — native TLS + mutual-TLS client authentication

Native TLS termination on the main listener (off by default — deployments commonly terminate TLS at an ingress). Protocol floor: TLS 1.2+, per IETF BCP 195. client_auth = "required" is the IHE ATNA ITI-19 mutually-authenticated-node posture (see the Audit trail chapter). The separate-port management listener always stays plain HTTP.

KeyTypeDefaultDescription
enabledboolfalseTerminate TLS natively on the main listener.
cert_filepathunsetServer certificate chain (PEM). Required when enabled.
key_filepathunsetServer private key (PEM). Required when enabled.
client_authenum{off,optional,required}offClient-certificate policy: required rejects any client without a verified certificate at the handshake.
client_ca_filepathunsetThe explicit CA bundle client certificates must chain to (never the web PKI). Required unless client_auth = "off".

[server.identity]

The System-Options manifest identity (OPTIONS on the API base path, e.g. OPTIONS /ferroehr/rest/openehr/v1 — the System API’s one location). Defaults are measured, not asserted — the manifest never out-claims the last conformance verdict. This is the deployment’s display identity only; the identifier stamped into authored data is server.system_id.

KeyTypeDefaultDescription
solutionstringFerroEHRProduct name.
solution_versionstringbuild versionProduct version.
vendorstringFerroEHR projectProviding organisation.
restapi_specs_versionstringtested-contract identityopenEHR REST API edition the build is tested against.
conformance_profilestringlast CNF verdictAdvertised conformance profile.

[db]

PostgreSQL connection.

[db]
url = "postgres://ferroehr:ferroehr@localhost:5432/ferroehr"
max_connections = 20
min_connections = 2
acquire_timeout_secs = 30
KeyTypeDefaultDescription
urlsecret URLpostgres://ferroehr:ferroehr@localhost:5432/ferroehrConnection DSN. The default matches the compose dev stack; production MUST set it. Credentials are redacted from every rendering. DATABASE_URL is a recognized lower-priority alias.
max_connectionsint20Pool ceiling. Write-heavy deployments benefit from 50+.
min_connectionsint2Idle connections kept open (avoids cold-reopen churn).
acquire_timeout_secsint30Seconds to wait for a free connection before failing.

[log]

[log]
format = "auto"
filter = "info,ferroehr=info"
KeyTypeDefaultDescription
formatenum{auto,json,pretty}autoStdout rendering; auto picks json when stdout is not a TTY (and suppresses the boot banner).
filterstringinfo,ferroehr=infoBoot EnvFilter directives; also the /management/loggers reset target. RUST_LOG is a recognized lower-priority alias.

[telemetry]

OpenTelemetry export. Unset otlp_endpoint ⇒ the OTel layer is not installed (zero overhead).

KeyTypeDefaultDescription
otlp_endpointstringunsetOTLP/gRPC collector endpoint.
service_namestringferroehrservice.name resource attribute.
environmentstringdevdeployment.environment resource attribute.
traces_sample_ratiofloat1.0Head-sampling ratio (0.1 is a common prod start).
metrics_pushboolfalseAlso push metrics over OTLP alongside the Prometheus pull surface.

[auth]

Authentication (Basic + OAuth2/OIDC bearer).

[auth]
enabled = true
verified_cache_ttl_seconds = 60

[[auth.basic.users]]
username = "clinician"
password_hash = "$argon2id$v=19$..."   # Argon2 PHC hash, never plaintext
roles = ["USER"]

[auth.oidc]
issuer = "https://keycloak.example.com/realms/ferroehr"
audiences = ["ferroehr"]
algorithms = ["RS256"]
KeyTypeDefaultDescription
enabledbooltrueMaster switch. false = all requests pass unauthenticated (dev only). With true and no mechanism configured, every API request 401s (fail-closed).
verified_cache_ttl_secondsint60Verified Basic-credential cache TTL (0 disables); bounds Argon2 cost per busy client and revocation lag alike.

[[auth.basic.users]] — the Basic-auth user store (array of tables, file-only):

KeyTypeDefaultDescription
usernamestringrequiredPrincipal name.
password_hashsecretrequiredArgon2 PHC hash ($argon2id$v=19$…), never a plaintext password.
roleslist of string["USER"]Roles granted (upper-cased on authentication).

[auth.oidc] — bearer validation (absent table ⇒ bearer disabled):

KeyTypeDefaultDescription
issuerstringrequired when presentExpected iss; also the OIDC discovery base.
audienceslist of string[]Accepted aud (empty = not checked).
algorithmslist of string["RS256"]Accepted signature algorithms.
hmac_secret / hmac_secret_filesecret / pathunsetSymmetric HS256 secret (dev/test). At most one of the pair.
jwks_json / jwks_json_filestring / pathunsetStatic JWKS document; preferred over discovery when present.

[authz]

RBAC + ABAC.

[authz.rbac]:

KeyTypeDefaultDescription
enabledbooltrueCoarse role gate (active when auth is enabled).
admin_rolestringADMINRole required for admin-class operations.
user_rolestringUSERBaseline clinical role.
readonly_rolestringREADONLYRole marking a principal read-only: refused on every write operation (create/update/delete/upload), even alongside granting roles. Reads and AQL queries are still allowed.
role_claimslist of string["realm_access.roles","scope"]JWT claim paths mined for roles.
management_accessenum{admin_only,private,public}admin_onlyAccess level for the management surface.

[authz.abac]:

KeyTypeDefaultDescription
enabledboolfalseMaster ABAC switch.
engineenum{cedar,remote}cedarEmbedded Cedar or a remote decision point.
organization_claimstringorganization_idJWT claim carrying the caller’s organization.
patient_claimstringpatient_idJWT claim carrying the patient id.

[authz.abac.cedar]: policy_dir (path — required when engine=cedar and ABAC on), reload_secs (int, unset — optional hot-reload interval). [authz.abac.remote]: server (string — required when engine=remote, must end /), connect_timeout_ms (int, 2000), request_timeout_ms (int, 5000). [authz.abac.policy.<kind>] (kind ∈ ehr, ehr_status, composition, contribution, query, directory): name (string), parameters (list of enum{organization,patient,template}).

[admin]

KeyTypeDefaultDescription
enabledboolfalseMount the ADMIN API (physical, irreversible delete). Off ⇒ every admin route answers 405 Method Not Allowed with an empty Allow header, never 403.

[tenancy]

Multi-tenancy.

KeyTypeDefaultDescription
enabledboolfalseActivate tenant middleware + row-level scoping.
claimstringtenantJWT-claim path carrying the tenant key.
headerstringunsetDev-only request-header tenant override. Leave unset in production — a client header must not select a tenant.

[smart]

SMART App Launch. Off by default; when off the discovery document is not served and the scope gate is inert. See SMART App Launch.

[smart]: enabled (bool, false), platform_base_url (string, unset ⇒ REST root), public_base_url (string, required when enabled — the external origin, e.g. https://cdr.example.com, from which the discovery document’s absolute services.*.baseUrl values are built), ehr_id_claim (string, ehrId), patient_claim (string, patient), require_smart_scopes (bool, false — when true the SMART resource-scope gate is fail-closed across the composition, template, and AQL families, and the openehr-permission-v1 capability is advertised; when false the gate is advisory and the capability is not claimed), launch_base64_json (bool, false). [smart.episode]: enabled (bool, false). [smart.endpoints]: issuer, jwks_uri, authorization_endpoint, token_endpoint, registration_endpoint, introspection_endpoint, revocation_endpoint, management_endpoint (all string, unset ⇒ omitted from the discovery document); token_endpoint_auth_methods_supported, grant_types_supported, response_types_supported, code_challenge_methods_supported, scopes_supported, capabilities (all list of string, []capabilities appends operator-advertised HL7 base capabilities such as launch-ehr/sso-openid-connect to the derived openEHR set). Deprecated grant types (implicit/password) are rejected at boot; enabled = true additionally requires public_base_url, authorization_endpoint, and token_endpoint at boot.

[management]

The ops-introspection surface (build info, Prometheus, metric views, effective config, runtime log control). Off in the bare binary and every endpoint off individually.

The health probes are not configured here: /health, /health/liveness, and /health/readiness are always served on the main API port without authentication, whatever this section says (see Operations → Health probes).

[management]
enabled = false
base_path = "/management"
access_default = "admin_only"

[management.endpoints]
info = "off"
metrics = "off"
prometheus = "off"
env = "off"
loggers = "off"
KeyTypeDefaultDescription
enabledboolfalseMount the management router.
base_pathstring/managementBase path for the management endpoints.
portintunset ⇒ share the main listenerServe management on its own listener/port. Must differ from the server.bind port.
access_defaultenum{off,admin_only,private,public}admin_onlyGlobal default access level (a per-endpoint level wins).

[management.endpoints]info, metrics, prometheus, env, loggers, each enum{off,admin_only,private,public}, default off.

Warning

probes_enabled and endpoints.health were removed. Configuration is strict, so a file or environment variable still setting either one fails at boot with an unknown-key error — delete the key; the probes are always on.

[signing]

VERSION signing. On by default in digest mode, with read-time verification of the server’s own signatures strict by default.

KeyTypeDefaultDescription
enabledbooltrueServer-side signing of committed versions.
modeenum{digest,pgp}digestSHA-256 integrity digest, or an OpenPGP (RFC 4880) detached signature.
key_pathpathunsetArmored secret key; required for pgp.
key_passphrase / key_passphrase_filesecret / pathunsetKey passphrase.
verify_on_readenum{off,warn,strict}strict when signing is enabledRead-time recompute-and-compare policy for the server’s own signatures.

Note

verify_on_read defaults to strict when signing is enabled. On every read the server recomputes the signature of a version it signed and, on a mismatch, returns a 500 integrity fault rather than silently serving a provably corrupt record. Set it explicitly to warn (log + emit version_signature_invalid_total, still serve) or off (never check) to opt out. Client-supplied signatures — an author’s own signature, or one carried by an imported version — are always stored verbatim and never re-verified (the author may have signed a different agreed serialization), regardless of this setting.

Warning

pgp mode fails closed at boot if the key is missing or unusable — the server will not start. Verify the key and passphrase before switching modes.

[query]

AQL execution knobs.

KeyTypeDefaultDescription
plan_cache_capacityint256Max distinct cached query plans; 0 disables the cache. Cache activity is reported by the aql_plan_cache_events_total metric.
timeout_msint0Per-query DB execution budget; 0 disables (the global request timeout remains). Overrun returns 408.

[events]

Contribution-outbox eventing → AMQP, plus its admin API. Off by default; envelopes are PHI-free by design.

KeyTypeDefaultDescription
enabledboolfalseSpawn the outbox publisher (with fhir.outbound.enabled, gates the per-commit outbox INSERT).
urlsecret URLamqp://guest:guest@localhost:5672/%2fAMQP broker URL; credentials redacted from every rendering.
exchangestringferroehr.eventsTopic exchange (PHI-free envelope stream).
tlsboolfalseUpgrade amqp:// to amqps://.
batch_sizeint128Rows drained per poll.
poll_interval_msint1000Idle poll interval.
retention_daysint7Published-row retention window.
prune_interval_secsint3600Retention-prune cadence.
publish_max_retriesint3Per-row publish retries before backing off.
admin_apiboolfalseMount the /admin/event_subscription CRUD routes.

[fhir]

The FHIR connector — an inbound façade and an independent outbound emitter.

[fhir]: api_enabled (bool, false) — mount /fhir/r4/* + /admin/fhir_mapping. [fhir.outbound]: enabled (bool, false), url (secret URL, same AMQP default), exchange (string, ferroehr.fhir — deliberately distinct from the events exchange for PHI isolation), tls (bool, false), batch_size (int, 128), poll_interval_ms (int, 1000), publish_max_retries (int, 3).

Warning

The outbound stream carries PHI — the mapped FHIR resource. It is a deliberately separate switch and exchange from the PHI-free change-event stream so broker access control can isolate it. Enable it only against a TLS, access-controlled broker.

[terminology]

Terminology extension API and external FHIR-terminology servers.

[terminology]: api_enabled (bool, false) — mount the terminology extension API. [terminology.external]: enabled (bool, false), fail_on_error (bool, false — on TS/connectivity error, reject vs accept). [terminology.external.providers.<name>] (conventionally default): type (enum{fhir}, fhir), url (string, required), operation (enum{validate_code,expand}, validate_code), connect_timeout_ms (int, 2000), request_timeout_ms (int, 10000), oauth2_client (string, unset — must name an entry under [terminology.external.oauth2_clients]), client_cert_path / client_key_path (paths, unset — the mutual-TLS client identity, see below), ca_bundle_path (path, unset — the trust anchors for this server, see below), cache_ttl_secs (int, 300 — TTL of the per-provider response cache; a repeated validate/expand/subsumes/lookup within the window is served locally instead of one HTTPS round trip per validated code; 0 disables), cache_capacity (int, 10000 — maximum cached responses per provider).

Several terminology servers at once

Every entry under [terminology.external.providers] is materialised at startup, so one instance can serve SNOMED CT from one server and LOINC or ICD from others. [terminology.external.routes] maps a terminology to the provider that answers for it — the key is a terminology id (SNOMED-CT) or a system URI (http://snomed.info/sct), matched case-insensitively as a whole string, and the value names a provider. A terminology with no route goes to the provider named default, or to the sole configured provider when there is exactly one. A route naming a provider that does not exist is a startup error.

[terminology.external]
enabled = true
fail_on_error = false

[terminology.external.providers.default]
type = "fhir"
url = "https://r4.ontoserver.csiro.au/fhir"

[terminology.external.providers.snomed]
type = "fhir"
url = "https://snowstorm.example.org/fhir"
oauth2_client = "ts-client"

[terminology.external.routes]
"SNOMED-CT" = "snomed"
"http://snomed.info/sct" = "snomed"
"http://loinc.org" = "default"

Routing applies everywhere terminology is consulted: the /terminology/* extension API, AQL TERMINOLOGY(…) resolution, and the composition-commit binding checks below.

Authenticating to a terminology server

[terminology.external.oauth2_clients.<name>] configures an OAuth2 client-credentials client; a provider references it by name with oauth2_client. The access token is cached and re-requested shortly before it expires, so a validation burst costs one token request per token lifetime.

Keys: token_url (string, required), client_id (string, required), client_secret (secret — or client_secret_file pointing at a file holding it; exactly one of the two), scopes (list of strings, empty), refresh_leeway_secs (int, 30 — how long before expiry the token is renewed), auth_method (enum{client_secret_basic,client_secret_post}, client_secret_basic).

[terminology.external.oauth2_clients.ts-client]
token_url = "https://idp.example.org/realms/ts/protocol/openid-connect/token"
client_id = "ferroehr-cdr"
client_secret_file = "/run/secrets/ts-client"
scopes = ["system/*.read"]

Mutual TLS to a terminology server

A terminology server that authenticates its clients with certificates instead of (or in addition to) a bearer token is configured per provider, because a client certificate is issued by that server’s PKI: a deployment enrolled with a national SNOMED CT service, a commercial value-set server and an in-house HAPI server holds three different certificates. Repeat the same paths in each provider table if one identity really does serve them all.

Keys on [terminology.external.providers.<name>]:

KeyMeaning
client_cert_pathPEM file with the client certificate (optionally a chain) presented to this server.
client_key_pathPEM file with that certificate’s private key.
ca_bundle_pathPEM bundle of the trust anchors this server’s certificate is verified against.
[terminology.external.providers.snomed]
type = "fhir"
url = "https://snowstorm.example.org/fhir"
client_cert_path = "/run/secrets/ts-snomed-client.crt.pem"
client_key_path = "/run/secrets/ts-snomed-client.key.pem"
ca_bundle_path = "/run/secrets/ts-snomed-ca.pem"

client_cert_path and client_key_path are set together — one without the other is a startup error, never a connection that silently presents no certificate. Unreadable files, a certificate file with no certificate in it and a key file with no key in it are startup errors too, so a broken identity never waits until the first validated code to surface.

ca_bundle_path replaces the default trust anchors for that provider, so a terminology server issued by a private PKI is pinned to that PKI instead of also accepting the whole public web PKI. Leave it unset to use the platform’s default trust store.

Important

There is no option to disable certificate verification. Server-certificate and hostname verification are always on for every provider; ca_bundle_path changes which anchors are trusted, never whether the server is verified.

The client identity applies to the connection to the terminology server itself. An OAuth2 token endpoint (oauth2_client) is a different host in a different trust domain and keeps the default TLS stack.

Kubernetes deployments mount the PEM files with the chart’s config.files map (see the Helm chart values), which materialises them under /etc/ferroehr/.

Archetype value-set bindings at commit

With [terminology.external] enabled, committing a COMPOSITION also resolves the archetype constraint bindings its template declares: where a template binds an ac code to an external terminology query, the coded value in the composition must be a member of the value set that query returns. The query is sent to the server the binding’s terminology routes to.

  • The code is in the value set → the commit proceeds.
  • The code is not in the value set → 422 naming the path, the code, and the bound query. This is a real constraint violation, so fail_on_error does not change it.
  • The value set could not be resolved (server down, error response, no provider routes to that terminology) → fail_on_error decides: false (default) accepts the commit and logs a warning; true rejects it with 422.

With [terminology.external] enabled = false (the default) no binding is resolved and no request is made, so commit behaviour is exactly as before.

Note

The composition’s terminology_id is sent verbatim as the FHIR system parameter. If your archetypes use SNOMED-CT where your terminology server expects http://snomed.info/sct, configure the server to accept the id your archetypes carry — the CDR does not rewrite it.

[multimedia]

DV_MULTIMEDIA externalization → S3-compatible object store. Off by default (blobs stay inline, byte-identical).

KeyTypeDefaultDescription
enabledboolfalseExternalize large multimedia data.
threshold_bytesint262144 (256 KiB)Decoded size strictly above which data is offloaded.
endpointstringunset ⇒ AWS defaultS3-compatible endpoint.
bucketstringopenehr-multimediaTarget bucket.
regionstringus-east-1AWS region (required even for non-AWS endpoints).
access_key_idstringunsetS3 access key id (unset + no secret = anonymous).
secret_access_key / secret_access_key_filesecret / pathunsetS3 secret access key.
allow_httpboolfalseAllow plain-HTTP endpoints — dev only; prod S3 is HTTPS.

[audit]

The IHE ATNA audit trail (see the Audit trail chapter). On by default with only the local store active; forwarding is opt-in per sink. (Replaces the former [atna] section — old keys fail at boot with did-you-mean guidance.)

KeyTypeDefaultDescription
enabledbooltrueMaster audit switch.
enterprise_site_idstringunsetAuditEnterpriseSiteID.
source_idstringferroehrAudit source id.
value_if_missingstringUNKNOWNFill value for empty mandatory fields.
suppress_login_eventsbooltrueSkip successful-login records (rejections are always recorded).
fail_modeenum{open,closed}openOn undeliverable audit: succeed and meter (open) or reject auditable operations with 503 (closed — includes an unhealthy local store).
resolve_subjectbooltrueEnrich the patient participant via a background subject lookup.
queue_capacityint8192Bounded audit queue capacity (sized for write-path bursts; the drain persists in multi-row batches).
server_hoststringunsetThis node’s advertised address (NetworkAccessPointID).

[audit.store] — the local Audit Record Repository

KeyTypeDefaultDescription
enabledbooltruePersist every record in the audit schema (served via the ITI-81 GET /fhir/r4/AuditEvent search).
retention_daysint0Days to keep records; 0 = keep forever. Applied hourly.

[audit.syslog] — the classic DICOM/syslog feed (ITI-20)

KeyTypeDefaultDescription
enabledboolfalseShip DICOM PS3.15 records to an external ARR over syslog.
hoststringlocalhostARR host.
portint514ARR port (514 UDP / 6514 TLS typical).
transportenum{udp,tls}udpSyslog transport. Use tls for PHI-adjacent audit.
tls_ca_file / tls_identity_cert_file / tls_identity_key_filepathunsetPEM CA / client cert / client key for the TLS transport.

[audit.fhir_feed] — the RESTful-ATNA feed (ITI-20 ATX:FHIR Feed)

KeyTypeDefaultDescription
enabledboolfalsePOST each FHIR AuditEvent to an external FHIR ARR. Outbox-driven (loss-free) when the local store is on.
urlurlhttp://localhost:8080/fhirThe ARR’s FHIR base; records go to {url}/AuditEvent. URL credentials are redacted from every rendering.
batch_sizeint64Outbox rows shipped per poll.
poll_interval_msint2000Outbox poll interval when idle.
max_retriesint3Per-record POST retries before the record is left pending (store on) or dropped + metered (store off).

[subject_proxy]

FHIR frames. Empty by default — no external FHIR system is reachable until one is named here (fail-closed). Systems are keyed by the name subject-proxy frames use as their system_id. See Subject Proxy.

[subject_proxy.systems.<name>]: base_url (string, required per system), connect_timeout_ms (int, 2000), request_timeout_ms (int, 10000).

[subject_proxy.systems.pas]
base_url = "https://pas.example.com/fhir"

The env form for a named system is FERROEHR__SUBJECT_PROXY__SYSTEMS__PAS__BASE_URL.

Process / CLI

VariableTypeDefaultDescription
FERROEHR_HEALTHCHECK_URLURLhttp://127.0.0.1:8080/ferroehr/rest/statusTarget URL for the binary’s healthcheck subcommand (container HEALTHCHECK and Kubernetes exec probes). Not part of ferroehr.toml.

The config subcommands

ferroehr config default             # print the annotated default ferroehr.toml
ferroehr config check [--config P]  # validate (file + env + --set), print the
                                   #   effective config (secrets redacted) with
                                   #   a provenance column; exit 0 on success, 1 on error

ferroehr config check runs the exact same three validation passes as boot but touches no database — use it in CI and before a rollout.

Zero-config boot and the production checklist

With no file and no environment, the server boots as: listener 0.0.0.0:8080 at the ITS-REST base path with Swagger UI; DB at the compose-dev DSN; auth enabled with no mechanism ⇒ every API request 401s (fail-closed; boot logs a prominent warning naming the two ways out — add [[auth.basic.users]] / [auth.oidc], or set auth.enabled = false for dev); RBAC on; signing on (digest); log auto/info; everything else off.

For production, set at least:

  • db.url — the real DSN, via FERROEHR__DB__URL (from a secret) or a *_file-mounted value, never inline in a world-readable file.
  • an auth mechanism — a Basic user store and/or [auth.oidc].
  • log.format = "json" for cluster log collectors.
  • server.cors_permissive stays false; server.swagger_ui per posture.
  • server.system_id — this deployment’s own openEHR system identifier (default ferroehr.local). Choose it before the first EHR is created: it is stored with every EHR, audit entry, and version identifier.
  • management.* per posture (a dedicated port is recommended so /management is never reachable on the clinical listener).
  • TLS everywhere a transport supports itaudit.syslog.transport = "tls", events.tls, fhir.outbound.tls, HTTPS S3.
  • real secrets via env or *_file, never inline.

What belongs in a mounted file (vs env)

Env cannot carry an array of tables, so the Basic-auth user store ([[auth.basic.users]]) is file-only. Genuinely file-shaped material — the PGP signing key, Cedar/ABAC policies, ATNA TLS PEMs, a JWKS blob — is referenced by an in-TOML *_path / *_file key pointing at a mounted path (e.g. the Helm chart’s config.files). Everything else is a plain key you can set in the file or override with an FERROEHR_* env var.

See docker/ferroehr.dev.toml in the repository for a worked dev example (server section, CORS, admin, and the Basic-auth users).

Migrating from 3.x environment variables

The pre-redesign layout used ~14 independent loaders, several env-name grammars, and nine FERROEHR_*_CONFIG file pointers. Every old server variable now fails at boot with the exact uniform replacement suggested — there is no legacy alias layer (greenfield: nothing was deployed to migrate). The table below maps every old spelling to its replacement.

Old variableNew key (env form)Fate
FERROEHR_DB_URLdb.url (FERROEHR__DB__URL)boot error — use the new spelling
DATABASE_URLdb.urlkept permanently
FERROEHR_DB_MAX_CONNECTIONS / _MIN_CONNECTIONS / _ACQUIRE_TIMEOUT_SECSdb.* (FERROEHR__DB__*)boot error — use the new spelling
FERROEHR_LOG_FORMAT / FERROEHR_LOG_FILTERlog.* (FERROEHR__LOG__*)boot error — use the new spelling
RUST_LOGlog.filterkept permanently
FERROEHR_OTEL_*telemetry.*boot error — use the new spelling
FERROEHR_REST_CONFIG--config / FERROEHR_CONFIG + ferroehr.tomlremoved — merge the file into ferroehr.toml
FERROEHR_REST_BIND / _BASE_PATH / _SWAGGER_UI / _CORS_PERMISSIVEserver.* (FERROEHR__SERVER__*)boot error — use the new spelling
FERROEHR_REST_MAX_IN_FLIGHTserver.max_in_flight (FERROEHR__SERVER__MAX_IN_FLIGHT)boot error — use the new spelling
FERROEHR_REST_SYSTEM__*server.identity.*boot error — use the new spelling
FERROEHR_REST_AUTH__ENABLED / _VERIFIED_CACHE_TTL_SECONDSauth.* (FERROEHR__AUTH__*)boot error — use the new spelling
FERROEHR_REST_AUTH__OIDC__*auth.oidc.* (FERROEHR__AUTH__OIDC__*)boot error — use the new spelling
FERROEHR_REST_AUTH__BASIC__USERS[[auth.basic.users]] (file-only)removed — set in the file
FERROEHR_REST_AUTH__ADMIN_SCOPE— (subsumed by authz.rbac.admin_role)removed
FERROEHR_REST_ADMIN__ENABLEDadmin.enabledboot error — use the new spelling
FERROEHR_REST_TENANCY__*tenancy.*boot error — use the new spelling
FERROEHR_REST_TERMINOLOGY__ENABLEDterminology.api_enabledboot error — use the new spelling
FERROEHR_REST_EVENT_SUBSCRIPTION__ENABLEDevents.admin_apiboot error — use the new spelling
FERROEHR_REST_FHIR__ENABLEDfhir.api_enabledboot error — use the new spelling
FERROEHR_REST_SMART__*smart.* (FERROEHR__SMART__*)boot error — use the new spelling
FERROEHR_MANAGEMENT_*management.* (FERROEHR__MANAGEMENT__*)boot error — use the new spelling
FERROEHR_MANAGEMENT_ENDPOINTS_<EP>management.endpoints.<ep> (FERROEHR__MANAGEMENT__ENDPOINTS__<EP>)boot error — use the new spelling
FERROEHR_AUTHZ_RBAC__* / FERROEHR_AUTHZ_ABAC__*authz.rbac.* / authz.abac.* (FERROEHR__AUTHZ__…)boot error — use the new spelling
FERROEHR_ATNA_<KEY>audit.<key> (FERROEHR__AUDIT__<KEY>)boot error — use the new spelling
FERROEHR_SIGNING_<KEY>signing.<key> (FERROEHR__SIGNING__<KEY>)boot error — use the new spelling
FERROEHR_EVENTS_<KEY>events.<key> (FERROEHR__EVENTS__<KEY>)boot error — use the new spelling
FERROEHR_FHIR_OUTBOUND_<KEY>fhir.outbound.<key> (FERROEHR__FHIR__OUTBOUND__<KEY>)boot error — use the new spelling
FERROEHR_MULTIMEDIA_<KEY>multimedia.<key> (FERROEHR__MULTIMEDIA__<KEY>)boot error — use the new spelling
FERROEHR_VALIDATION_EXTERNAL_TERMINOLOGY_*terminology.external.*boot error — use the new spelling
FERROEHR__SUBJECT_PROXY__SYSTEMS__*subject_proxy.systems.* — same spelling, now actually bindsbinds for the first time
FERROEHR__QUERY__PLAN_CACHE_CAPACITY / _TIMEOUT_MSquery.* — same spelling, now strict-parsedbehaviour change (bad values now error)
the nine FERROEHR_*_CONFIG file pointersremoved — merge each file’s contents into ferroehr.toml under its [section]

The PostgreSQL init container variables FERROEHR_DB_USER / _PASSWORD / _NAME were renamed to PG_INIT_USER / _PASSWORD / _DB — they configure the database container, not the server, and no longer collide with the server’s reserved FERROEHR_ namespace.