Skip to main content

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

SettingDescription
ModeIsolated - each tenant has separate data. (Distributed planned but not yet implemented)
TenantIdUnique identifier. 0 is reserved for system operations
FhirVersion4.0 (R4), 4.3 (R4B), 5.0 (R5), or 6.0 (R6)
Storage.TypeSqlEntityFramework (recommended)
InheritConnectionStringFromTenantSystem 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 Location headers, 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:

  1. If the request's Host header matches a configured hostname, that tenant is selected.
  2. If the URL path contains /tenant/{id}/ (numeric), that tenant is selected by ID.
  3. If both Host header and /tenant/{id}/ path are present and resolve to different tenants, the server returns 400 Bad Request.
  4. If the Host header 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/hosts or 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) ✅
  • Host header routing with Hostnames

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

ProviderUse CaseConfiguration Section
LocalDevelopment - stores in RootDirectory on filesystemLocalFileBlobStorage
AzureProduction - Azure Blob Storage with Managed Identity or connection stringAzureBlobStorage

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. $reindex and $import have no HTTP request to derive a base from. With Fhir:BaseUri unset 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 — $import does this via FhirRequestContextFactory.CreateBackgroundContext, restored on exit so it cannot leak to the next job on a pooled thread. Any future background path that indexes resources (a $reindex implementation, for example) must do the same or it will silently reintroduce this gap even with Fhir:BaseUri set.
  • Host header trust. With Fhir:BaseUri unset, the base is derived from the request's Host header, which a client controls — a forged Host decides whether an inbound reference is stored as internal or external. When it is set, the Host header is ignored for this purpose. Independently, set AllowedHosts to your real hostnames rather than leaving it at *.
note

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

RoleDescription
AdminFull access to all resources
SystemAdminCross-tenant administrative access
ClinicianAccess to clinical resources (Patient, Observation, etc.)
ReadOnlyRead-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
}
}
}
}
FeatureDescription
McpModel Context Protocol for AI integration
TransformFHIR Mapping Language $transform operation
Terminology$expand, $translate, $subsumes operations
SummaryPatient $summary (IPS) operation

Bulk Import Tuning

Configure import performance for high-volume ingestion:

{
"Import": {
"MaxConcurrentFiles": 1,
"ConsumerCount": 1,
"BatchSize": 100,
"ChannelCapacity": 1000
}
}
SettingDefaultDescription
MaxConcurrentFiles1Files processed in parallel (default 1, increase for higher throughput)
ConsumerCount1Writer threads per file (default 1, increase to 4-8 for parallel processing)
BatchSize100Resources per database write
ChannelCapacity1000Backpressure buffer size
note

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
tip

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