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

Configuring the server

FerroBRIDGE reads an optional TOML file, then environment variables over it. Nothing else configures the process. No specification governs this; it is FerroBRIDGE’s own design.

Where the file comes from

ferrobridge serve --config /etc/ferrobridge/config.toml names the file. With no flag, FERROBRIDGE_CONFIG names it. With neither, the defaults below stand and the server serves with no upstream configured.

How an environment variable maps to a key

Prefix FERROBRIDGE__, then the dotted key with two underscores between levels. Case does not matter; single underscores inside a key name stay as they are.

FERROBRIDGE__SERVER__LISTEN            -> [server] listen
FERROBRIDGE__CDR__BASE_URL             -> [cdr] base_url
FERROBRIDGE__CDR__RETRY__MAX_ATTEMPTS  -> [cdr.retry] max_attempts

A value takes the type of the key it sets. A string key takes the text as it is, whatever its characters: FERROBRIDGE__CDR__CREDENTIALS__USER=12345 sets the user name 12345, and true or 1.5 stay text too. Quotes are part of the value, so do not add any. A number, boolean or array key reads the text as TOML syntax: 5 is the number 5, true is a boolean and ["a","b"] is an array. Text that does not read as the key’s type is refused at boot, naming the key.

The variables

Every key below is settable in the file and as FERROBRIDGE__<SECTION>__<KEY>. A key marked secret also accepts a <key>_file sibling naming a file the server reads once at boot, with surrounding whitespace trimmed.

[server]

KeyDefaultMeaning
listen127.0.0.1:8080The socket address to bind
request_timeout_ms30000How long one request may take before the server answers 408
shutdown_timeout_ms10000How long the drain may take after SIGTERM or SIGINT
body_limit_bytes1048576The largest request body read before the server answers 413

[telemetry]

KeyDefaultMeaning
formatautoauto, json or pretty; auto is pretty on a terminal and json otherwise
filterinfo,hyper=warn,tower=warn,h2=warnA RUST_LOG-style directive; a directive that does not parse falls back to this default and says so once
logged_query_parameters[]The query parameter names whose values may reach the request log, each value cut at 64 characters

Nothing else about a request is logged. Add a name to logged_query_parameters only for a parameter you know carries no identifying content.

The console

Two more variables set the console for one run, and they win over the file and over FERROBRIDGE__TELEMETRY__FORMAT and FERROBRIDGE__TELEMETRY__FILTER:

VariableSetsValues
FERROBRIDGE_LOG_FORMAT[telemetry] formatauto, json or pretty; any other value refuses the start with exit 78
RUST_LOG[telemetry] filterA tracing filter directive such as debug,hyper=warn
FERROBRIDGE_LOG_FORMAT=pretty RUST_LOG=debug ferrobridge serve

auto writes pretty with colour on a terminal and json without colour anywhere else, so a container or a log pipeline gets one JSON object per line with no configuration. An explicit pretty keeps its colour into a pipe. Colour only wraps text, so a line reads the same once a collector strips the escapes. In pretty, a carriage return or line feed inside a logged value is written as the two characters \r or \n, so no value can start a second line; json escapes it inside the string.

Under pretty, ferrobridge serve prints a banner to stdout before the first log line: the wordmark, the version, the pins this build serves, the commit and build instant, and each lane with the hosts it reaches. A host is the URL’s host and port and never its user information. Under json nothing but log lines reaches stdout.

The first boot events describe the process:

EventFields
consoleformat, rendering, filter (the one in effect, after a fallback), colour
buildversion, commit, built_at, rustc, openehr_crates, fhir_types
lanelane (facade, operations, etl or terminology), enabled, identifiable, and where set cdr_host, terminology_host, cdm_host, cdm_schema, bridge_schema, contexts, programs, templates

listening is the last boot event. The etl lane reports what ferrobridge etl run would reach; serve never runs it.

GET /health/info answers the same build facts and pins as JSON, so a scraper reads what the banner shows:

{
  "product": "FerroBRIDGE",
  "version": "0.0.3",
  "build": {
    "commit": "0123456789abcdef0123456789abcdef01234567",
    "commit_short": "0123456789ab",
    "built_at": "2026-01-01T00:00:00Z",
    "rustc": "rustc 1.98.1 (48a229cea 2026-09-01)"
  },
  "pins": [
    { "name": "FHIRconnect", "version": "v1.0.0" },
    { "name": "FHIR", "version": "R4 (4.0.1)" }
  ]
}

The pin list is shortened here; the route lists every pin the banner prints. built_at is the SOURCE_DATE_EPOCH instant when the build sets one, and the commit is unknown for a build outside a git checkout.

[cdr]

The openEHR CDR lane. Absent, the bridge holds no CDR client and readiness reports no CDR indicator. This section reaches identifiable data, and the server says so once at start-up, naming the section and never a value.

KeyDefaultSecretMeaning
base_urlnone, requiredThe openEHR REST API root, usually ending in /v1
timeout_ms30000How long one call may take, connection included

[cdr.retry] and [terminology.retry]

KeyDefaultMeaning
max_attempts3How many times a call is sent at most, the first try included; 1 disables retry
initial_backoff_ms200The delay before the second attempt
max_backoff_ms5000The ceiling every later delay is clamped to

For the CDR, the bridge calls through the generated ITS-REST client of the openehr-its crate, and the three keys are that client’s retry budget. Only a GET, PUT or DELETE is sent again, after a failure to connect, a timeout, or a 5xx other than 501; a POST is sent once. A 401, a 403 and a status the operation does not document are never retried. [cdr] timeout_ms is the per-request timeout of the client’s HTTP engine.

[cdr.credentials] and [terminology.credentials]

Set one scheme. A bearer token and a user together is a boot error, and so is an inline value beside its _file sibling.

KeyDefaultSecretMeaning
bearer_tokennoneyesAn RFC 6750 bearer token
usernoneThe user name of RFC 7617 basic authentication; a user_file sibling names a file holding it, read at boot like a secret
passwordnoneyesThe password of RFC 7617 basic authentication

For the CDR, the scheme becomes the Authorization header of every call the generated client sends: Bearer <token> for a token, and Basic over user:password for a user and a password.

[terminology]

The FHIR terminology lane. Absent, the bridge holds no terminology client, and the engines fail closed on anything that would need one.

KeyDefaultMeaning
base_urlnone, requiredThe FHIR service base URL
wire_versionr4r4 or r4b, the release the server answers in
timeout_ms30000How long one call may take, connection included

[cdm]

The OMOP CDM database lane. This section reaches identifiable data, and the server says so once at start-up.

KeyDefaultSecretMeaning
urlnone, required when the section is presentyesThe PostgreSQL connection URL; it must name its sslmode
tls_canonenoThe PEM CA the database’s certificate is checked against
tls_ca_filenonenoA file holding that CA, read at boot
schemacdmnoThe schema the CDM tables live in, an unquoted lower-case identifier
bridge_schemaferrobridgenoThe schema the bridge keeps its natural-key side table, id sequences and watermarks in
person_policycreate_on_first_sightnocreate_on_first_sight gives an EHR a person_id the first time a row refers to it; existing refuses a row whose EHR has no PERSON row yet

Two clients open the database: the concept resolver’s pool and the CDM writer’s own connection. Both read the sslmode of url and the CA from tls_ca or tls_ca_file, so they agree on whether the connection is encrypted and what it trusts. The modes are the libpq ones that never fall back to plaintext (https://www.postgresql.org/docs/current/libpq-ssl.html):

sslmodeWhat the clients do
disableConnect without TLS. A CA with disable is refused
requireConnect over TLS. With a CA, the certificate must chain to it, as libpq checks when a root certificate is present; without one, it is not checked
verify-caConnect over TLS; the certificate must chain to the CA, or to the webpki root set when no CA is set
verify-fullAs verify-ca, and the certificate must name the host in url

prefer and allow are refused when the configuration is read, naming the mode: a client that honours them connects in plaintext when the server offers no TLS, and that fallback is silent. A URL that names no sslmode is refused the same way, because libpq’s default is prefer: write sslmode=disable for a database on a trusted local network or in a test container, and one of the three TLS modes everywhere else. Any other ssl parameter in the URL (sslrootcert, sslcert, sslkey and the rest) is refused too: the CA comes from tls_ca_file, and the bridge sends no client certificate. For the same reason the bridge refuses to start when PGSSLROOTCERT, PGSSLCERT or PGSSLKEY is set in its environment: the pool’s PostgreSQL client reads those variables whatever the configuration says, and the writer does not. The CA is public material, so it is not a secret, and FERROBRIDGE__CDM__TLS_CA and FERROBRIDGE__CDM__TLS_CA_FILE set the two keys from the environment; setting both is refused. A typical production URL, with the CA mounted beside it:

[cdm]
url_file = "/run/secrets/cdm_url"   # postgres://bridge:...@cdm.internal:5432/omop?sslmode=verify-full
tls_ca_file = "/run/secrets/cdm_ca.pem"

What cdm init does

ferrobridge cdm init reads which CDM tables [cdm] schema already holds before it applies anything:

The schema holdscdm init
none of the 39 CDM v5.4 tables, or does not existCreates the schema when absent, then applies OHDSI’s tables, primary keys and indices in one transaction, and exits 0
every CDM v5.4 tableApplies nothing, prints that the schema is already initialised with the cdm_version its CDM_SOURCE rows record, and exits 0
some of the tablesApplies nothing and exits 1, naming every missing table

It then creates the bridge schema, which is safe to repeat. Tables outside the CDM’s 39 are left alone and do not count. The check reads table names only, so a schema whose tables exist without their keys or indices counts as initialised.

OHDSI’s fourth file, OMOPCDM_postgresql_5.4_constraints.sql, carries the foreign keys and is left out by default. cdm init --with-constraints applies it after the tables, statement by statement in one transaction of its own. At the pinned OHDSI tag v5.4.3 PostgreSQL refuses line 157, a foreign key onto vocabulary (vocabulary_id) that the primary keys file gives no key (SQLSTATE 42830), so the flag exits 1, prints that statement, and leaves the tables in place and no foreign key behind.

[etl]

The OMOP ETL job that ferrobridge etl run runs. Both queries are parsed and checked when the configuration loads, so a query the runner cannot read refuses every job.

KeyDefaultSecretMeaning
aqlnone, requirednoThe composition query. It aliases ehr_id, version_uid and the whole composition, carries an ORDER BY and no LIMIT. It may name $since, which --since binds. It may also alias versioned_object_uid, which the runner then checks against the version; when it does not, the runner reads it from the version_uid, whose object_id part names the versioned object (openEHR RM Common 1.1.0, OBJECT_VERSION_ID)
page_size100noThe fetch of each page of either query
type_concept_idnone, requirednoThe *_type_concept_id the mappings write, the provenance of the records
observation_period_type_concept_idnone, requirednoThe period_type_concept_id of every observation period

[etl.visits]

The visit derivation, off until the section is present. The rows are grouped by EHR and source, each visit spanning the earliest start to the latest end.

KeyDefaultSecretMeaning
aqlnone, requirednoThe visit query, aliasing ehr_id, visit_source, visit_start and visit_end, with an ORDER BY
visit_concept_idnone, requirednoThe visit_concept_id of every visit
visit_type_concept_idnone, requirednoThe visit_type_concept_id of every visit

[mappings]

The mapping sets this deployment runs. The facade, the two FHIRconnect operations and the mapping subcommands read the FHIRconnect set in directory; etl run reads the OMOCL set in omocl. The FHIRconnect set and its templates are read once at boot, and a mapping that does not compile refuses the start rather than the first request that touches it. The OMOCL set is read once when etl run starts and compiled against each template the first time a composition of it arrives, because an OMOCL file names an archetype and no template.

KeyDefaultMeaning
directorynoneThe directory the mapping files are read from, recursively (.yml, .yaml)
templatesnoneThe directory holding the operational templates the two operations compile against, as OPT 1.4 XML (.opt)
omoclnoneThe directory the OMOCL files ferrobridge etl run maps with are read from, recursively (.yml, .yaml); read and validated once at start, and every file an Include names must be in it

The three directories are walked the same way. Every real subdirectory is read, a symlink to a file is followed, and a symlink to a directory is not. An entry whose name starts with .. is skipped, so a Kubernetes ConfigMap mounted as a whole directory is read once: its files sit in a hidden ..<timestamp> directory behind a ..data symlink, and each key appears at the top level as a file symlink.

The facade always takes its templates from the CDR and needs directory alone. The two FHIRconnect operations take theirs from one of two sources:

  • With templates set, every .opt file in that directory is read. This source wins when [cdr] is configured too, because the directory is the explicit choice, and the operations then run with no CDR at all.
  • With templates unset and [cdr] configured, the server reads the context files first and fetches each template they name from the CDR (GET /definition/template/adl1.4/{template_id}, openEHR ITS-REST 1.1.0 §Definition). A template the CDR does not hold, or any other refused fetch, stops the start with an error naming the template and the status the CDR answered, so the lane never comes up with part of its mappings.

A directory with neither templates nor [cdr] is refused while the operations are enabled. The server checks this when it reads the configuration, before it calls any upstream, and the refusal names both sources. With [operations] enabled = false, directory alone is enough for the facade.

# The operations compile against local templates and reach no CDR.
[mappings]
directory = "/etc/ferrobridge/mappings"
templates = "/etc/ferrobridge/templates"
# The operations compile against the templates the CDR serves.
[cdr]
base_url = "https://cdr.example.org/openehr/v1"

[mappings]
directory = "/etc/ferrobridge/mappings"

[facade]

The FHIR R4 facade. It is off until enabled is true, and a disabled facade mounts no route, so a request to /fhir answers 404 rather than 403. An enabled facade needs [cdr] and [mappings] directory: it compiles the mapping set at boot against the templates the CDR holds, so a mapping that does not compile refuses the start rather than the first request that touches it. This lane reaches identifiable data, and the server says so once at start-up.

KeyDefaultMeaning
enabledfalseWhether the facade routes are mounted
base_urlhttp://127.0.0.1:8080/fhirThe absolute FHIR service base a client reaches, which Location is written under
ehr_policyexistingexisting writes only into an EHR the CDR already holds; create_on_first_write creates one for an unknown subject
identity_storeidentity.redbThe file the identity map is kept in, opened at boot and refused when it cannot be written
subject_namespaceferrobridgeThe namespace a subject identifier is looked up in on the CDR
system_idFerroBRIDGEThe AUDIT_DETAILS.system_id every commit records
composition_languagenone, requiredThe COMPOSITION.language every commit carries, an ISO 639-1 code
composition_territorynone, requiredThe COMPOSITION.territory every commit carries, an ISO 3166-1 code

The two composition fields have no default. FHIRconnect puts them on the project performing the mapping, and there is no correct language for a clinical record you did not write, so an enabled facade with either key unset is a boot error naming the key.

The identity store holds identifiers: a subject to its ehr_id, a resource id to the composition it came from, and the source identity of every resource the facade consumed. No clinical content is written into it. Back it up with the CDR, because a lost map means the next create writes a second composition for a resource the CDR already holds.

[hl7v2]

The HL7 v2 face: an MLLP listener whose messages are written into the CDR through the facade’s ingest service. It is off until enabled is true, and an enabled face with the facade off is a boot error naming hl7v2.enabled and facade.enabled. This lane reaches identifiable data. The HL7 v2 face page states what it does with a message and how it answers.

KeyDefaultMeaning
enabledfalseWhether the MLLP listener runs
listen127.0.0.1:2575The socket address the listener binds
default_charsetASCIIThe HL7 table 0211 code a message with an empty MSH-18 is read in
concept_mapsnone, requiredThe package directory of hl7.fhir.uv.v2mappings, loaded at boot
supplements[]ConceptMap directories loaded over the guide, in order
unmapped_entriesskip_and_countskip_and_count or refuse, for an entry no program maps
ehr_policythe facade’sexisting or create_on_first_write, for the face alone
profiles[]{ resource_type, profile } pairs: the profile a resource of that R4 type claims when the guide wrote none
idle_timeout_ms300000How long a connection may sit between messages; 0 keeps it open
frame_timeout_ms30000How long one frame may take to arrive whole; 0 waits
frame_limit_bytes1048576The largest message one frame may carry
log_outcomesfalseWhether an AE or AR also logs the counted outcomes by kind at debug level
senders[]The MSH-4 sending facilities accepted: { namespace_id } or { universal_id, universal_id_type }; empty accepts any

A resource type the R4 element table does not name, a profile that is not a URL, a resource type listed twice, and a sender that names neither a namespace id alone nor a universal id with its type are each a boot error naming the key.

[operations]

The $tofhir and $toopenehr lane. Both operations are pure transformations and reach no CDR on a request, so they are served whenever [mappings] directory is set and its templates load, from [mappings] templates or from the CDR at boot. Without a mapping directory the two routes answer 503.

KeyDefaultMeaning
enabledtrueWhether the two operations and their direct forms are served
device_referenceDevice/ferrobridge-<version>The Provenance agent.who a call that supplies no context.who gets
composerFHIRconnectThe composition composer an inbound run fills in when no mapping does
composition_languagenoneThe composition language, an ISO 639-1 code
composition_territorynoneThe composition territory, an ISO 3166-1 alpha-2 code

The FHIRconnect engine chapter puts the composer and the context start time on the engine and the composition language and territory on the project performing the mapping, so set the last two: a template whose reference model requires them refuses to build a composition without them, and $toopenehr then answers an OperationOutcome naming the missing field.

The secrets, in one place

VariableFile sibling
FERROBRIDGE__CDR__CREDENTIALS__BEARER_TOKENFERROBRIDGE__CDR__CREDENTIALS__BEARER_TOKEN_FILE
FERROBRIDGE__CDR__CREDENTIALS__PASSWORDFERROBRIDGE__CDR__CREDENTIALS__PASSWORD_FILE
FERROBRIDGE__TERMINOLOGY__CREDENTIALS__BEARER_TOKENFERROBRIDGE__TERMINOLOGY__CREDENTIALS__BEARER_TOKEN_FILE
FERROBRIDGE__TERMINOLOGY__CREDENTIALS__PASSWORDFERROBRIDGE__TERMINOLOGY__CREDENTIALS__PASSWORD_FILE
FERROBRIDGE__CDM__URLFERROBRIDGE__CDM__URL_FILE

Prefer the _file route: a Docker or Kubernetes secret arrives as a file, and a file never shows up in docker inspect or a process listing.

What a bad configuration does

The server refuses to start and prints one line on stderr naming the key, then exits 78, EX_CONFIG. Five things are refused:

  • an unknown key or an unknown section;
  • a value of the wrong type, or one that is not a socket address, a URL, or a release name;
  • a [cdm] url that names no sslmode, or one that falls back to plaintext or is not a libpq mode, a [cdm] CA that holds no PEM certificate, and a PGSSLROOTCERT, PGSSLCERT or PGSSLKEY in the environment;
  • a secret set both inline and through its _file sibling;
  • a section that is present and names no value for a key it needs.

An example

[server]
listen = "0.0.0.0:8080"

[telemetry]
format = "json"

[cdr]
base_url = "https://cdr.example.org/ehrbase/rest/openehr/v1"

[cdr.credentials]
bearer_token_file = "/run/secrets/cdr-token"

[terminology]
base_url = "https://tx.example.org/r4"
wire_version = "r4"

[mappings]
directory = "/etc/ferrobridge/mappings"

[facade]
enabled = true
base_url = "https://bridge.example.org/fhir"
ehr_policy = "create_on_first_write"
identity_store = "/var/lib/ferrobridge/identity.redb"
subject_namespace = "https://example.org/fhir/sid/patient"
composition_language = "en"
composition_territory = "GB"