Helios FHIR Server (HFS)
A high-performance FHIR server built in Rust.
An open test server is available at https://hfs.heliossoftware.com/ for experimentation and evaluation.
Features
- Full FHIR RESTful API support
- Multiple FHIR version support
- Pluggable storage backends (SQLite, PostgreSQL, MongoDB)
- Content negotiation (JSON)
- Conditional operations with ETag support
- Multi-tenant support via X-Tenant-ID header
- CORS support
Installation
From Source
# Clone the repository
# Build with default features (R4 + SQLite)
# Build with all FHIR versions
Usage
Running the Server
# Run with default settings (R4, SQLite, port 8080)
# Specify a different port
# Use an in-memory database
# Enable debug logging
Command Line Options
Usage: hfs [OPTIONS]
Options:
--port <PORT> Server port [env: HFS_SERVER_PORT=] [default: 8080]
--host <HOST> Host to bind [env: HFS_SERVER_HOST=] [default: 127.0.0.1]
--log-level <LOG_LEVEL> Log level (error, warn, info, debug, trace)
[env: HFS_LOG_LEVEL=] [default: info]
--database-url <URL> Database connection URL [env: DATABASE_URL=]
--data-dir <PATH> Path to FHIR data directory containing search parameter
definitions [env: HFS_DATA_DIR=] [default: ./data]
--max-body-size <BYTES> Maximum request body size [env: HFS_MAX_BODY_SIZE=]
[default: 134217728]
--request-timeout <SECS> Request timeout in seconds [env: HFS_REQUEST_TIMEOUT=]
[default: 30]
--enable-cors Enable CORS [env: HFS_ENABLE_CORS=] [default: true]
--cors-origins <ORIGINS> Allowed CORS origins [env: HFS_CORS_ORIGINS=] [default: *]
-h, --help Print help
-V, --version Print version
hfs --version prints the crate version and, when the binary was built from a
git checkout, the commit it was cut from — hfs 0.2.1 (git 1a2b3c4d5). The
same identity is advertised to FHIR clients as CapabilityStatement.software
on GET /metadata (the commit as an extension on software) and reported by
GET /health. When building from a source archive with no .git, set
HFS_GIT_SHA=<sha> in the build environment to record the commit.
Configuration
Environment Variables
| Variable | Default | Description |
|---|---|---|
HFS_SERVER_PORT |
8080 | Server port |
HFS_SERVER_HOST |
127.0.0.1 | Host to bind |
HFS_BASE_URL |
http://localhost:8080 | Public HTTP(S) base for response links and Location headers |
HFS_LOG_LEVEL |
info | Log level (error, warn, info, debug, trace) |
HFS_UI_ENABLED |
true | Serve the web UI at /ui; false for headless deployments (/ui then returns 404 + OperationOutcome) |
DATABASE_URL |
fhir.db | Database connection string |
HFS_DATA_DIR |
./data | Path to FHIR data directory (search parameters) |
HFS_SEARCH_PARAM_CACHE_TTL |
3600 | Seconds between refreshes of the in-memory SearchParameter registry from storage; a param POSTed to one cluster node becomes visible to others within this interval. 0 disables the refresh. |
HFS_MAX_BODY_SIZE |
134217728 | Max request body size (bytes; applies to the decompressed body for compressed requests). The 128 MiB default covers the largest per-patient Synthea Bundle (#1662); a batch or transaction body is parsed whole in memory, so lower it on small hosts. A bundle that size takes minutes to process (the largest, 27,056 entries, took about 2 minutes on a release build with SQLite), and HFS_REQUEST_TIMEOUT can answer 408 before it finishes: raise that too when posting large bundles. |
HFS_REQUEST_TIMEOUT |
30 | Request timeout (seconds) |
HFS_DASHBOARD_RECONCILE_SECS |
30 | Seconds between Home dashboard reconcile passes against storage (CLI --dashboard-reconcile-interval-secs). Whole seconds, must be > 0; 0 or a non-numeric value is a startup error. A tenant's full recount is additionally spaced to at most every max(interval, 10 × its last recount duration); also how soon a failed background seed is retried. |
HFS_DASHBOARD_REFRESH_SECS |
5 | Seconds between refreshes of a Home dashboard whose figures are moving (approximate, or an import running) (CLI --dashboard-refresh-secs). Re-reads in-memory counters only, never storage. Whole seconds, must be > 0 and <= HFS_DASHBOARD_IDLE_REFRESH_SECS; otherwise a startup error. |
HFS_DASHBOARD_IDLE_REFRESH_SECS |
10 | Seconds between watch ticks of a Home dashboard whose figures are settled (exact, no import running), so an open tab notices an import starting (CLI --dashboard-idle-refresh-secs). Whole seconds, must be > 0 and >= HFS_DASHBOARD_REFRESH_SECS; otherwise a startup error. |
HFS_ENABLE_CORS |
true | Enable CORS |
HFS_CORS_ORIGINS |
* | Allowed CORS origins |
HFS_CORS_METHODS |
GET,POST,PUT,DELETE,OPTIONS | Allowed HTTP methods |
HFS_CORS_HEADERS |
Content-Type,Authorization,X-Requested-With | Allowed headers |
HFS_DEFAULT_TENANT |
default | Default tenant ID |
HFS_TERMINOLOGY_SERVER |
(none) | Terminology server URL for :in/:not-in modifiers and FHIRPath memberOf()/subsumes() |
HFS_COMPOSITE_SYNC_MODE |
asynchronous |
Composite-store write sync mode for ES-backed backends (sqlite-elasticsearch, postgres-elasticsearch, mongodb-elasticsearch, s3-elasticsearch). One of asynchronous, synchronous, hybrid. With asynchronous (default) the write returns as soon as the primary commits and the search backend is updated on a background worker — lowest latency, but a follow-up search can race the indexing. Use synchronous when callers need read-your-write semantics (e.g. integration tests, bulk-load flows that immediately search). Ignored when the storage backend has no search secondary. |
HFS_ELASTICSEARCH_REFRESH_INTERVAL |
1s |
Elasticsearch index refresh_interval for ES-backed backends. Controls how quickly indexed documents become searchable when no per-write refresh is requested. -1 disables periodic refresh entirely. Set when an index is created and reconciled at startup onto every existing {HFS_ELASTICSEARCH_INDEX_PREFIX}_* index, overwriting a value set by hand with PUT _settings. With HFS_ELASTICSEARCH_WRITE_REFRESH=wait_for the interval is the price of every interactive write, so keep it short (1s or less); a long value makes each write wait for the next refresh (#1749). |
HFS_ELASTICSEARCH_WRITE_REFRESH |
false |
The refresh parameter applied to Elasticsearch index/delete operations. One of false (no per-write refresh), wait_for (block each write until the affected shards refresh), or true (force a refresh per write; expensive, low-volume deployments only). |
HFS_ELASTICSEARCH_MAX_TERMS_COUNT |
65536 |
Elasticsearch index.max_terms_count: the most values a single terms query may carry. A chained or _has search whose terminal hop resolved more ids than this is sent as several terms clauses of at most this many values each, instead of being refused (#1548). Applied to new indices through the index template. |
HFS_ELASTICSEARCH_NESTED_OBJECTS_LIMIT |
50000 |
Elasticsearch index.mapping.nested_objects.limit: the maximum nested objects a single document may contain, summed across every nested search-parameter field. Elasticsearch's own default of 10000 rejects larger documents, so a resource with very many indexed values (for example a Synthea Provenance with 13,554 target references) is stored but never searchable. Applied to new indices through the index template, and raised at startup on existing indices that are below it; the setting is dynamic, so no reindex is needed for resources that already indexed. |
HFS_ELASTICSEARCH_REQUEST_TIMEOUT_MS |
30000 |
Timeout of every Elasticsearch HTTP request, in milliseconds, including _bulk. A _bulk request that times out is split in half and resent, down to a single document, so raise this only when one large document still times out on its own. |
HFS_ELASTICSEARCH_BULK_MAX_BYTES |
10485760 (10 MiB) |
Byte cap on one _bulk request body, applied together with the 500-operation cap, for bulk creates and every rebuild. Without it, 500 Synthea Provenance resources (~108 KB each) made a ~54 MB request that exceeded the request timeout. A single document larger than the cap is sent alone. |
HFS_ELASTICSEARCH_BULK_CONCURRENCY |
1 |
How many _bulk requests of one page are in flight at once. 1 sends them one at a time, as before #1125. Raising it shortens a rebuild when the cluster is not the bottleneck; splitting after a 413 or a timeout, and 429 back-off, stay sequential within the request that caused them. |
HFS_ELASTICSEARCH_REINDEX_PREPARE_THREADS |
0 |
Threads that extract search values for one $reindex page on the Elasticsearch writer, which is what the composite deployments' post-import rebuild runs through. 0 uses cores − 1, clamped to 1–4; 1 extracts on the page's own thread, as before #1250; an explicit value is capped at 64. Pages of fewer than 16 resources, and any page on a current-thread runtime, extract inline. |
HFS_REINDEX_BATCH_BYTES |
33554432 (32 MiB) |
Byte cap on one page of the automatic rebuild, on top of HFS_REINDEX_BATCH_SIZE. 0 means count only; with a cap set, a page of ~108 KB Provenance resources stays near the cap instead of holding ~108 MB in memory. Honoured by the SQLite source (a page may exceed the cap by one resource) and by the PostgreSQL and MongoDB sources (a page never exceeds the cap unless it holds a single resource); the Elasticsearch and S3 sources page by count only. |
HFS_REINDEX_PREFETCH |
true |
Whether the search-index rebuild fetches the next page from the primary while the current one is written, for POST $reindex and the automatic post-import rebuild alike. Honoured wherever the source allows the overlap: standalone MongoDB and mongo-es (id phase), and sqlite-es, postgres-es and s3-es, where search is offloaded so the page read and the Elasticsearch write touch disjoint stores; standalone SQLite and PostgreSQL keep the serial loop. false restores the strictly serial fetch → write loop on every backend (#1250). HFS_MONGODB_REINDEX_PREFETCH remains the MongoDB-specific switch underneath. |
HFS_ELASTICSEARCH_REINDEX_REFRESH |
(unset) | The refresh parameter for the _bulk writes of $reindex and of the deferred post-import rebuild: false, wait_for, or true. Unset follows HFS_ELASTICSEARCH_WRITE_REFRESH. Set false to let a rebuild skip the per-request refresh wait while ordinary writes keep wait_for. |
HFS_REINDEX_BATCH_SIZE |
1000 |
Page size of the automatic deferred rebuild that follows a bulk import, on every backend. POST $reindex keeps its own batchSize parameter (default 100), capped at 10,000. |
Read-after-write search on an ES-backed composite needs both
HFS_COMPOSITE_SYNC_MODE=synchronous and
HFS_ELASTICSEARCH_WRITE_REFRESH=wait_for. See
Search visibility on Elasticsearch-backed composites
in the persistence crate for why either setting alone still leaves a window.
Elasticsearch _bulk failures are handled per shape. A request that times out,
or is answered 413, 408 or 504, is split in half and each half resent,
down to a single document; once a single document times out, the rest of the
page fails as transient instead of being split further. A 429, for the whole
request or for single items, is retried with bounded exponential back-off for
the rejected items only. A per-document 4xx is permanent and not retried. A
connection error, a whole-request 5xx, or a 429 that outlasts its retries
fails every document of that request as transient. Every failed document is
recorded by Type/id, with Elasticsearch's error.type and reason when it
gave one, in GET /$reindex-status/{job_id}. See
Bulk writes and rebuilds on Elasticsearch-backed composites.
On sqlite-elasticsearch, the recommended pair for a bulk import is
HFS_ELASTICSEARCH_WRITE_REFRESH=wait_for with
HFS_ELASTICSEARCH_REINDEX_REFRESH=false: ordinary writes stay
read-your-write, while the rebuild skips the per-request refresh wait.
Measured on a 228,580-resource cut, that took the deferred rebuild from 806 s
to 145 s (1,576 resources/s) with the same result.
Recommended HFS_ELASTICSEARCH_REFRESH_INTERVAL: keep the 1s default (#1749).
A long interval does not shorten a bulk import and it makes every interactive
write slower. Measured on postgres-elasticsearch (PostgreSQL 16, Elasticsearch
8.15.0 with an 8 GB heap, 8 CPU / 23 GB, HFS_BULK_SUBMIT_DEFER_INDEXING=false,
HFS_COMPOSITE_SYNC_MODE=synchronous, HFS_ELASTICSEARCH_WRITE_REFRESH=wait_for,
919,752 resources, three runs per value in rotated order):
REFRESH_INTERVAL |
Import (mean of 3) | Elasticsearch CPU (mean) | Refreshes | Single POST /Basic, p50 |
|---|---|---|---|---|
1s |
223 s | ~95 % | ~365 | 1.0 s |
5s |
220 s | ~75 % | ~144 | 5.0 s |
30s |
220 s | ~40 % | ~95 | not measurable: HFS did not become ready |
-1 |
not measurable with wait_for |
- Import: the three intervals are within 2 % of each other, far under the 10 % that would justify a dynamic interval, so none is built. The longer interval only saves Elasticsearch CPU; the import is limited by HFS and PostgreSQL.
- Interactive writes: with
wait_fora write, a batch of 8 and a transaction of 662 entries each take about one interval (1.0 s at1s, 5.0 s at5s), the same with or without an import running.HFS_ELASTICSEARCH_WRITE_REFRESH=trueorfalsereturns in under 0.1 s (p95) at any interval, but then a write is not searchable on return (false) or each write forces a refresh (true). - A new index takes the configured interval: the first write of a type never
written before created
hfs_default_riskassessmentwith the configured value, so a long interval also slows the first save of each type. 30sand-1withwait_forare not usable: HFS seeds the search parameters withwait_forwrites at startup. On an empty cluster at30sthat took 212 s in two runs, and in two other attempts it was still not ready after 2 and after 10 minutes (each seed write timing out at the 30 s request timeout and being retried); with-1the server was not ready after 10 minutes in three runs. HFS now logs a warning at startup for these combinations.- Larger corpus (25 % cut of the full Synthea corpus, 4,738,958 resources in all
24 types, same machine and settings): the long interval still does not pay for
itself. Imported with
1s: 1,363 s (3,477 resources/s), 2,828 refreshes, Elasticsearch CPU ~97 %. With30s: 1,328 s (3,568 resources/s, 2.6 % faster), 391 refreshes, Elasticsearch CPU ~54 %. On the same loaded server, with no configuration change, aPOST /Basic, a batch of 8, a 662-entry transaction and the first write of a new type (RiskAssessment) took about 1.0 s at1sand about 30 s (p95 and maximum 60 s) at30s. - Not measured: the full 18.96 M-resource corpus (the import rate falls as the
index grows: an interrupted full run averaged ~2,460 resources/s at 64 %, so the
25 % figures are optimistic) and the real 8-entry batch and 662-entry transaction
Bundles (the numbers above use synthetic
BasicBundles of the same size). The during-import pass overlapped only the end of a 100-bundle import.
A manifest ingested with indexing deferred records that it still owes a
rebuild, on its own row. If the server stops mid-rebuild, the next start
re-fires it and logs resuming search-index rebuilds left outstanding by an earlier run; the marker is cleared once the rebuild has run.
Set HFS_BASE_URL to the public address clients can reach. The value may
contain a path prefix. It must be an absolute http or https URL without
credentials, a query string, or a fragment. HFS rejects invalid values and
does not infer the public address from request forwarding headers.
FHIR Version Support
Build with specific FHIR versions using feature flags:
# R4 only (default)
# R5 only
# Multiple versions
Database Backends
SQLite (Default)
# Run with file-based database
# Run with in-memory database
PostgreSQL
MongoDB
API Endpoints
| Interaction | Method | URL |
|---|---|---|
| capabilities | GET | /metadata |
| read | GET | /[type]/[id] |
| vread | GET | /[type]/[id]/_history/[vid] |
| update | PUT | /[type]/[id] |
| patch | PATCH | /[type]/[id] |
| delete | DELETE | /[type]/[id] |
| create | POST | /[type] |
| search | GET/POST | /[type]?params or /[type]/_search |
| history (instance) | GET | /[type]/[id]/_history |
| history (type) | GET | /[type]/_history |
| history (system) | GET | /_history |
| batch/transaction | POST | / |
| health | GET | /health |
Examples
Create a Patient
Read a Patient
Search for Patients
Get CapabilityStatement
Batch and Transaction Support
HFS supports both batch and transaction bundles for processing multiple operations in a single request.
Note: Transaction bundles require ACID transaction support. The default SQLite backend fully supports transactions. If using other backends, check the capability matrix in the persistence crate documentation.
Transaction Bundle (Atomic)
All operations succeed or all fail. Entries are processed in FHIR-specified order: DELETE → POST → PUT → GET.
The urn:uuid:patient-1 reference is automatically resolved to the actual Patient ID after creation.
Batch Bundle (Independent)
Each operation is processed independently; failures don't affect other entries.
Current Limitations
The following FHIR bundle features are not yet implemented:
- Conditional reference resolution (
Patient?identifier=12345) - PATCH method in bundles
- Prefer header handling (
return=minimal, etc.)
Search Parameter Configuration
HFS loads FHIR SearchParameter definitions from JSON bundle files to enable comprehensive search functionality. By default, these files are expected in a data/ directory relative to the working directory or executable.
Data Directory Structure
data/
├── search-parameters-r4.json # FHIR R4 SearchParameters (HL7 spec)
├── search-parameters-r4b.json # FHIR R4B SearchParameters (HL7 spec)
├── search-parameters-r5.json # FHIR R5 SearchParameters (HL7 spec)
├── search-parameters-r6.json # FHIR R6 SearchParameters (auto-downloaded at build time)
├── sql-on-fhir-search-parameters.json # SQL-on-FHIR IG ViewDefinition search params (custom file)
└── *.json # Custom SearchParameter files (see below)
Search Parameter Loading
On startup, HFS loads SearchParameters in this order:
- Minimal fallback - Built-in
_id,_lastUpdated,_tag,_profile,_security(always available) - Spec file - Loads from the appropriate
search-parameters-*.jsonbased on configured FHIR version - Custom files - Loads any additional
.jsonfiles in the data directory (not matchingsearch-parameters-*.json) - Stored parameters - Loads any custom SearchParameters POSTed to the server
Custom SearchParameter Files
You can add custom SearchParameters by placing JSON files in the data directory. Each file can contain:
- A single SearchParameter resource
- An array of SearchParameter resources
- A FHIR Bundle containing SearchParameter resources
Example custom SearchParameter file (data/custom-search-params.json):
Or as a Bundle:
SQL-on-FHIR ViewDefinition Search Parameters
data/sql-on-fhir-search-parameters.json ships the 17 ViewDefinition SearchParameters from the SQL-on-FHIR IG as one of these custom Bundle files. Do not edit it by hand — regenerate it from the official IG package with:
The script strips generated narratives and rewrites the composite parameters' legacy component.definition canonicals to the core-namespace canonicals the parameters actually declare (an upstream IG inconsistency, reported as HL7/sql-on-fhir#403; the rewrite becomes a no-op once fixed upstream). Bump DEFAULT_PACKAGE_URL in the script when a new IG ballot/release ships.
Custom Data Directory
Specify a custom location for the data files:
# Via command line
# Via environment variable
HFS_DATA_DIR=/opt/hfs/data
If the spec file is missing, HFS logs a warning and continues with minimal fallback parameters. This ensures the server can start even without the full spec files, though search functionality will be limited.
R6 Automatic Download
When building with the R6 feature enabled, the search-parameters-r6.json file is automatically downloaded from the HL7 build server during compilation. The download is skipped if:
- The file already exists and is less than 24 hours old
- The
DOCS_RSenvironment variable is set (for docs.rs builds)
Multi-Tenant Support
Use the X-Tenant-ID header to isolate data between tenants: