Configuration
Ignixa uses standard ASP.NET Core configuration with appsettings.json. All settings can be overridden via environment variables using double-underscore notation (e.g., Tenants__Mode).
Tenant Configuration (Required)
Ignixa requires at least two tenant configurations: Tenant 0 (system partition) and Tenant 1+ (your data).
{
"Tenants": {
"Mode": "Isolated",
"Configurations": [
{
"TenantId": 0,
"DisplayName": "System Partition (Reserved)",
"FhirVersion": "4.0",
"IsActive": true,
"IsSystemPartition": true,
"Storage": {
"Type": "SqlEntityFramework",
"InheritConnectionStringFromTenant": true
}
},
{
"TenantId": 1,
"DisplayName": "Production Database",
"FhirVersion": "4.0",
"IsActive": true,
"Storage": {
"Type": "SqlEntityFramework",
"ConnectionString": "Server=localhost;Database=FHIR_R4;Integrated Security=true;TrustServerCertificate=true"
}
}
]
}
}
Key Settings
| Setting | Description |
|---|---|
Mode | Isolated - each tenant has separate data. (Distributed planned but not yet implemented) |
TenantId | Unique identifier. 0 is reserved for system operations |
FhirVersion | 4.0 (R4), 4.3 (R4B), 5.0 (R5), or 6.0 (R6) |
Storage.Type | SqlEntityFramework (recommended) |
InheritConnectionStringFromTenant | System partition inherits from Tenant 1 |
Hostname-based Tenant Resolution
Each tenant may declare a Hostnames array to enable resolution by request Host header in addition to numeric /tenant/{id}/ path routing.
Configuration
{
"Tenants": {
"Configurations": [
{
"TenantId": 1,
"DisplayName": "Production Database",
"Hostnames": ["fhir1.example.org", "fhir1-backup.example.org"],
"Storage": { "Type": "SqlEntityFramework", "ConnectionString": "..." }
}
]
}
}
How It Works
Hostname Semantics:
- First hostname is the canonical base for that tenant's absolute references. This hostname is used when the server emits absolute URLs (in
Locationheaders, pagination links,Bundle.entry.fullUrl, etc.) and when stored internally. - Additional hostnames (if any) are recognized as valid inbound hosts for the same tenant but are not used for outbound references.
- Hostnames must be bare DNS names (lowercase, no scheme, no port, no path). Example:
fhir1.example.org(valid);https://fhir1.example.org:8080/fhir(invalid). - Hostnames are unique across all tenants. A duplicate hostname is fatal: the server refuses to serve and the error is enforced when the host-index resolver is first used or during host-index build.
Resolution Precedence:
- If the request's
Hostheader matches a configured hostname, that tenant is selected. - If the URL path contains
/tenant/{id}/(numeric), that tenant is selected by ID. - If both
Hostheader and/tenant/{id}/path are present and resolve to different tenants, the server returns 400 Bad Request. - If the
Hostheader is not recognized and no/tenant/{id}/is in the path, resolution falls through to single-tenant auto-detect (if only one active tenant) or remains unresolved.
Examples:
Request: GET http://fhir1.example.org/metadata
Result: Selects tenant with Hostnames[0] = "fhir1.example.org"
Request: GET http://fhir1-backup.example.org/Patient/123
Result: Selects same tenant via Hostnames[1] (alternate hostname)
Request: GET http://fhir1.example.org/tenant/2/Patient/123
Result: 400 Bad Request (Host resolves to Tenant 1, path specifies Tenant 2 — conflict)
Request: GET http://localhost/tenant/1/Patient/123
Result: Selects Tenant 1 (by path; Host not recognized)
Request: GET http://unrecognized.example.org/Patient/123
Result: Falls through to auto-detect or single-tenant mode
TLS/Certificate Considerations
- Subdomains under one zone (e.g.,
fhir1.example.org,fhir2.example.org) are covered by a single wildcard certificate (*.example.org). Wildcards match a single DNS level. - Apex/vanity domains (different registrable domains like
org1.com,org2.com) require separate certificates, each signed for its own domain. - For development, self-signed certificates or local DNS overrides (
/etc/hostsor Windows hosts file) are common.
Limitations
Path-based vanity slugs are not yet supported. The following forms are NOT available:
/tenant/{slug}/(path-based slug routing)/{slug}/(bare slug routing)
Currently, only these forms work:
/tenant/{id}/(numeric ID routing) ✅Hostheader routing withHostnames✅
Path-based slugs (/tenant/{slug}/) are planned for a future release and will require relaxing route constraints, slug indexing, and slug format validation across the API layer. Track progress in the project roadmap.
SQL Server Connection String
For production SQL Server:
{
"Storage": {
"Type": "SqlEntityFramework",
"ConnectionString": "Server=your-server.database.windows.net;Database=FHIR_R4;Authentication=Active Directory Default;TrustServerCertificate=true"
}
}
For local development with Windows Auth:
{
"Storage": {
"Type": "SqlEntityFramework",
"ConnectionString": "Server=(local);Database=FHIR_R4;Integrated Security=true;TrustServerCertificate=true"
}
}
Blob Storage
Configure blob storage for bulk import/export operations:
{
"BlobStorage": {
"Provider": "Azure",
"ContainerName": "fhirstorage",
"UseManagedIdentity": true,
"StorageAccountUri": "https://youraccount.blob.core.windows.net"
},
"AzureBlobStorage": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=youraccount;AccountKey=...;EndpointSuffix=core.windows.net",
"ContainerName": "fhirstorage",
"UseManagedIdentity": true,
"StorageAccountUri": "https://youraccount.blob.core.windows.net"
}
}
Provider Options
| Provider | Use Case | Configuration Section |
|---|---|---|
Local | Development - stores in RootDirectory on filesystem | LocalFileBlobStorage |
Azure | Production - Azure Blob Storage with Managed Identity or connection string | AzureBlobStorage |
For local development with filesystem:
{
"BlobStorage": {
"Provider": "Local"
},
"LocalFileBlobStorage": {
"RootDirectory": "fhir-exports"
}
}
For Azurite (Azure Storage emulator):
{
"BlobStorage": {
"Provider": "Azure",
"UseManagedIdentity": false
},
"AzureBlobStorage": {
"ConnectionString": "UseDevelopmentStorage=true",
"ContainerName": "fhirstorage"
}
}
DurableTask (Bulk Operations)
Bulk import/export uses DurableTask for orchestration. SQL Server backend is recommended:
{
"DurableTask": {
"Provider": "SqlServer",
"SqlServer": {
"TaskHubName": "ignixa"
}
}
}
The SQL Server provider uses the same database as Tenant 0 (system partition), eliminating additional infrastructure dependencies. Schema is created automatically on startup.
Alternative Providers
{
"DurableTask": {
"Provider": "AzureStorage",
"AzureStorage": {
"UseManagedIdentity": true,
"StorageAccountName": "youraccount",
"TaskHubName": "ignixa"
}
}
}
Service Base URI
Fhir:BaseUri is this deployment's public FHIR service root. Set it in every environment that runs
$reindex or $import.
{
"Fhir": {
"BaseUri": "https://fhir.example.org"
}
}
It is used to recognise a reference written as an absolute URL that points back at this server, so it
reconciles with the equivalent relative reference. Patient/p1, https://fhir.example.org/Patient/p1 and
https://fhir.example.org/tenant/1/Patient/p1 all name the same resource, and all three are stored — and
searched — the same way. Both the root and each tenant's /tenant/{id}/ base are recognised, so it does
not matter which route form a client used to write or to search.
Two things depend on setting it:
- Background indexing.
$reindexand$importhave no HTTP request to derive a base from. WithFhir:BaseUriunset they recognise nothing, so reindexed rows file self-references as external while the rows they replace filed them as internal, and those resources drop out of absolute searches. The server logs a warning at startup when the setting is missing. Recognising the tenant-scoped base also depends on the background activity establishing which tenant it is running for —$importdoes this viaFhirRequestContextFactory.CreateBackgroundContext, restored on exit so it cannot leak to the next job on a pooled thread. Any future background path that indexes resources (a$reindeximplementation, for example) must do the same or it will silently reintroduce this gap even withFhir:BaseUriset. - Host header trust. With
Fhir:BaseUriunset, the base is derived from the request'sHostheader, which a client controls — a forgedHostdecides whether an inbound reference is stored as internal or external. When it is set, theHostheader is ignored for this purpose. Independently, setAllowedHoststo your real hostnames rather than leaving it at*.
Only rows written after the setting is in place are affected. References already stored against a
self-referencing absolute base keep it until a $reindex.
Authentication
Configure OIDC authentication with any compliant provider (Entra ID, Okta, etc.):
{
"Authentication": {
"Authority": "https://login.microsoftonline.com/{tenant}/v2.0",
"Audience": "api://your-app-id"
}
}
The server discovers endpoints automatically from /.well-known/openid-configuration.
Authorization
Enable RBAC-based authorization:
{
"Authorization": {
"Enabled": true,
"RequireAuthentication": true,
"EnforceTenantIsolation": true,
"EnforceCapabilities": true
}
}
Default Roles
| Role | Description |
|---|---|
Admin | Full access to all resources |
SystemAdmin | Cross-tenant administrative access |
Clinician | Access to clinical resources (Patient, Observation, etc.) |
ReadOnly | Read-only access to all resources |
SMART on FHIR
{
"Authorization": {
"SmartOnFhir": {
"EnableSmartConfiguration": true,
"AuthorizeUrl": "https://your-idp.com/authorize",
"TokenUrl": "https://your-idp.com/token"
}
}
}
Experimental Features
Enable or disable experimental features:
{
"Experimental": {
"Enabled": true,
"Features": {
"Mcp": {
"Enabled": true,
"Transport": "http"
},
"Transform": {
"Enabled": true,
"TimeoutSeconds": 30
},
"Terminology": {
"Enabled": true,
"EnableAutoImport": true
},
"Summary": {
"Enabled": true,
"MaxResources": 1000
}
}
}
}
| Feature | Description |
|---|---|
Mcp | Model Context Protocol for AI integration |
Transform | FHIR Mapping Language $transform operation |
Terminology | $expand, $translate, $subsumes operations |
Summary | Patient $summary (IPS) operation |
Bulk Import Tuning
Configure import performance for high-volume ingestion:
{
"Import": {
"MaxConcurrentFiles": 1,
"ConsumerCount": 1,
"BatchSize": 100,
"ChannelCapacity": 1000
}
}
| Setting | Default | Description |
|---|---|---|
MaxConcurrentFiles | 1 | Files processed in parallel (default 1, increase for higher throughput) |
ConsumerCount | 1 | Writer threads per file (default 1, increase to 4-8 for parallel processing) |
BatchSize | 100 | Resources per database write |
ChannelCapacity | 1000 | Backpressure buffer size |
Higher concurrency values improve throughput but use more system resources and threads. Start with defaults and increase conservatively based on monitoring. Each concurrent file spawn 1 producer + ConsumerCount worker threads, so total threads = MaxConcurrentFiles * (1 + ConsumerCount).
Transaction Watcher
Automatically commits stalled transactions:
{
"TransactionWatcher": {
"Enabled": true,
"ScanInterval": "00:01:00",
"StallThreshold": "00:05:00"
}
}
Logging
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"Ignixa": "Debug"
}
}
}
For troubleshooting SQL queries, set EF Core command logging to Debug:
{
"Logging": {
"LogLevel": {
"Microsoft.EntityFrameworkCore.Database.Command": "Debug"
}
}
}
Environment Variables
Override any setting with environment variables:
# Tenant connection string
export Tenants__Configurations__1__Storage__ConnectionString="Server=..."
# Public FHIR service root (see "Service Base URI")
export Fhir__BaseUri="https://fhir.example.org"
# Enable authorization
export Authorization__Enabled=true
export Authorization__RequireAuthentication=true
# Blob storage
export BlobStorage__Provider=Azure
export BlobStorage__UseManagedIdentity=true
export BlobStorage__StorageAccountUri="https://account.blob.core.windows.net"
# DurableTask
export DurableTask__Provider=SqlServer
Docker/Container Deployment
When running in containers, use environment variables:
docker run -p 8080:8080 \
-e Tenants__Configurations__1__Storage__ConnectionString="Server=host.docker.internal;Database=FHIR_R4;..." \
-e BlobStorage__Provider=Azure \
-e BlobStorage__ConnectionString="DefaultEndpointsProtocol=https;..." \
-e ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
ghcr.io/brendankowitz/ignixa-fhir:release
Set ASPNETCORE_FORWARDEDHEADERS_ENABLED=true when behind a reverse proxy (App Service, AKS ingress) to correctly handle X-Forwarded-* headers.
Complete Production Example
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Ignixa": "Information"
}
},
"Authentication": {
"Authority": "https://login.microsoftonline.com/{tenant}/v2.0",
"Audience": "api://ignixa-fhir"
},
"Authorization": {
"Enabled": true,
"RequireAuthentication": true,
"EnforceTenantIsolation": true
},
"BlobStorage": {
"Provider": "Azure",
"UseManagedIdentity": true,
"StorageAccountUri": "https://youraccount.blob.core.windows.net",
"ContainerName": "fhirstorage"
},
"DurableTask": {
"Provider": "SqlServer",
"SqlServer": {
"TaskHubName": "ignixa"
}
},
"Tenants": {
"Mode": "Isolated",
"Configurations": [
{
"TenantId": 0,
"DisplayName": "System Partition",
"FhirVersion": "4.0",
"IsActive": true,
"IsSystemPartition": true,
"Storage": {
"Type": "SqlEntityFramework",
"InheritConnectionStringFromTenant": true
}
},
{
"TenantId": 1,
"DisplayName": "Production",
"FhirVersion": "4.0",
"IsActive": true,
"Storage": {
"Type": "SqlEntityFramework",
"ConnectionString": "Server=sql.example.com;Database=FHIR_R4;Authentication=Active Directory Default"
}
}
]
},
"Experimental": {
"Enabled": false
}
}
Next Steps
- Server Architecture - Understand the internal design
- Security Configuration - Set up authentication
- Multi-Tenancy - Configure tenant isolation