Skip to main content

Choose an isolation boundary

WSO2 FHIR Server supports two deployment patterns. Choose the isolation boundary before provisioning data or exposing client URLs.

Dedicated server and database​

Run one server and one PostgreSQL database for each tenant.

Use this model when:

  • Regulatory or contractual requirements demand physical separation.
  • Tenants need independent scaling, maintenance, backup, or retention policies.
  • Operational simplicity is more important than infrastructure consolidation.

The FHIR base URL does not require a tenant path:

https://tenant.example.com/fhir/r4

Shared server and database​

Run one service and database while scoping data with a tenant identifier and PostgreSQL row-level security.

No configuration flag is needed: the server always mounts both the bare FHIR base path and the tenant-prefixed routes, and the row-level security policies are part of the standard schema. Requests to the bare path use the default tenant scope; requests under a tenant prefix are scoped to that tenant.

The database role must NOT be a superuser

PostgreSQL superusers — and any role with BYPASSRLS — ignore Row-Level Security, which silently disables tenant isolation. Create a dedicated least-privilege role for the server and connect as it:

CREATE ROLE fhir_app LOGIN PASSWORD '…'; -- NOT a superuser
GRANT USAGE ON SCHEMA public TO fhir_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO fhir_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO fhir_app;

Run migrations as the owner/admin role; run the server as fhir_app. Single-tenant deployments that never use the tenant routes are unaffected either way.

Tenant-aware routes use:

/t/{tenant}/fhir/r4

Set BASE_URL to the bare FHIR base URL, without a tenant prefix. The server inserts /t/{tenant} into generated links (search Bundles, Location headers, the CapabilityStatement) automatically for tenant-prefixed requests.

Enable and use a tenant​

There is nothing to enable and no tenant to register. Every FHIR route is already mounted under /t/{tenant}, and a tenant begins to exist the first time you write to its path. The walkthrough below uses two tenants, acme and beta, on a stock deployment.

1. Write into a tenant. Use the tenant path instead of the bare base path:

Request
curl -i -X POST "http://localhost:9090/t/acme/fhir/r4/Patient" \
-H "Content-Type: application/fhir+json" \
-d '{"resourceType": "Patient", "name": [{"family": "Acme-Patient"}]}'

The Location header comes back tenant-scoped, so a client can follow it without knowing the tenancy rules:

Response headers
HTTP/1.1 201 Created
ETag: W/"1"
Location: http://localhost:9090/t/acme/fhir/r4/Patient/ef8e696f-cb28-4a2d-859a-8ffbc8868249/_history/1

2. Read it back inside the same tenant.

Request
curl -s -o /dev/null -w "%{http_code}\n" \
"http://localhost:9090/t/acme/fhir/r4/Patient/ef8e696f-cb28-4a2d-859a-8ffbc8868249"
Response
200

3. Confirm the boundary holds. The same id is not readable from another tenant, nor from the default scope — the row-level security policy filters it out, so the server answers 404 rather than revealing that the id exists elsewhere:

Request
# same resource id, different scopes
curl -s -o /dev/null -w "beta: %{http_code}\n" \
"http://localhost:9090/t/beta/fhir/r4/Patient/ef8e696f-cb28-4a2d-859a-8ffbc8868249"
curl -s -o /dev/null -w "default: %{http_code}\n" \
"http://localhost:9090/fhir/r4/Patient/ef8e696f-cb28-4a2d-859a-8ffbc8868249"
Response
beta: 404
default: 404

Searches are scoped the same way:

Request
for scope in "/t/acme" "/t/beta" ""; do
printf "%-8s " "${scope:-default}"
curl -sS "http://localhost:9090${scope}/fhir/r4/Patient?_total=accurate" | jq '.total'
done
Response
/t/acme 2
/t/beta 0
default 0

4. Check the generated links. Bundle navigation links stay inside the tenant, so paging a tenant-scoped search never escapes its scope:

Request
curl -sS "http://localhost:9090/t/acme/fhir/r4/Patient?_count=1" | jq '[.link[].url]'
Response
[
"http://localhost:9090/t/acme/fhir/r4/Patient?_count=1&_page=1",
"http://localhost:9090/t/acme/fhir/r4/Patient?_count=1&_page=1",
"http://localhost:9090/t/acme/fhir/r4/Patient?_count=1&_page=2"
]

The tenant's CapabilityStatement is served the same way, at /t/acme/fhir/r4/metadata.

note

Requests to the bare base path are not rejected — they run in the default tenant scope. Treat that scope as one more tenant: on a shared deployment, either route all traffic through a tenant prefix at the gateway, or accept that the bare path is its own isolated dataset.

warning

A URL tenant identifier is routing context, not proof of identity or authorization. Place an authenticated gateway or equivalent security layer in front of the service and bind the authenticated tenant to the routed tenant value.

Selection guide​

ConcernDedicatedShared
Data isolationInfrastructure boundaryDatabase policy boundary
ScalingPer tenantShared capacity
UpgradesIndependentCoordinated
Backup and restorePer tenantRequires tenant-aware procedures
Operational overheadHigherLower

Validate backups, connection pooling, privileged database access, and cross-tenant negative tests for the selected model.