David Le
Back to Blog
UncategorizedFebruary 5, 2026·5 min read

FhirHub Architecture Deep Dive

A deep dive into FhirHub's five Docker services, data flow patterns, dependency chains, and how authentication is handled at every layer from Keycloak to the .NET API gateway to the Next.js frontend.

D

David Le

In Part 1, I introduced FhirHub and its tech stack. Now let's look under the hood at how the five Docker services work together, how data flows through the system, and how authentication is handled at every layer.

FhirHub Docker Services Architecture

The Five Services

FhirHub runs as five Docker containers orchestrated by Docker Compose:

docker-compose.yml
├── postgres          (HAPI FHIR database)
├── hapi-fhir         (FHIR R4 server)
├── keycloak-postgres  (Keycloak database)
├── keycloak          (Identity provider)
└── fhirhub-api       (.NET 8 API gateway)

Each service has a specific role:

  1. postgres -- PostgreSQL 18 Alpine storing FHIR resources for HAPI. Health-checked with pg_isready.
  2. hapi-fhir -- The HAPI FHIR R4 server on port 8080. Configured for JSON encoding with external references enabled and CORS wide open for development. Health-checked against /fhir/metadata.
  3. keycloak-postgres -- A separate PostgreSQL instance for Keycloak. Keeping auth data isolated from clinical data is a security best practice.
  4. keycloak -- Keycloak 26 running in dev mode with realm auto-import. Exposes port 8180 externally, 8080 internally. Health-checked via TCP socket to the ready endpoint.
  5. fhirhub-api -- The .NET 8 API gateway built from a local Dockerfile. Connects to both HAPI FHIR and Keycloak internally. Exposes port 5197.

Dependency Chain

postgres ─────────> hapi-fhir ─────────> fhirhub-api
                                              │
keycloak-postgres ─> keycloak ────────────────┘

The API won't start until HAPI FHIR is healthy, and HAPI FHIR won't start until PostgreSQL is healthy. Keycloak follows a parallel dependency chain with its own database.

Data Flow: Reading a Patient

Here's what happens when a clinician opens a patient record:

Browser                Frontend              API Gateway           HAPI FHIR
  │                      │                      │                      │
  │  GET /patients/123   │                      │                      │
  │─────────────────────>│                      │                      │
  │                      │  GET /api/patients/123                      │
  │                      │  Authorization: Bearer <JWT>                │
  │                      │─────────────────────>│                      │
  │                      │                      │  Validate JWT        │
  │                      │                      │  Check policy:       │
  │                      │                      │   CanReadPatients    │
  │                      │                      │   PatientDataAccess  │
  │                      │                      │                      │
  │                      │                      │  GET /fhir/Patient/123
  │                      │                      │─────────────────────>│
  │                      │                      │                      │
  │                      │                      │  FHIR Patient JSON   │
  │                      │                      │<─────────────────────│
  │                      │                      │                      │
  │                      │                      │  Map to DTO          │
  │                      │  PatientDetailDTO    │                      │
  │                      │<─────────────────────│                      │
  │  Render patient view │                      │                      │
  │<─────────────────────│                      │                      │

Key points:

  • The frontend never talks directly to HAPI FHIR. All requests go through the .NET API gateway.
  • JWT validation happens at the API layer using Keycloak's OIDC discovery endpoint.
  • Two authorization policies are checked: role-based (CanReadPatients) and resource-level (PatientDataAccess).
  • FHIR resources are mapped to DTOs before returning to the frontend. This keeps FHIR complexity out of the UI code.

Authentication Flow

Authentication uses the SMART on FHIR pattern with Keycloak as the identity provider:

Browser              Keycloak              API Gateway
  │                      │                      │
  │  Redirect to /auth   │                      │
  │─────────────────────>│                      │
  │                      │                      │
  │  Login form          │                      │
  │<─────────────────────│                      │
  │                      │                      │
  │  Credentials + PKCE  │                      │
  │─────────────────────>│                      │
  │                      │                      │
  │  Authorization code  │                      │
  │<─────────────────────│                      │
  │                      │                      │
  │  Exchange code       │                      │
  │  (with code_verifier)│                      │
  │─────────────────────>│                      │
  │                      │                      │
  │  JWT access token    │                      │
  │<─────────────────────│                      │
  │                      │                      │
  │  API request + JWT   │                      │
  │──────────────────────────────────────────── >│
  │                      │                      │
  │                      │  Validate signature  │
  │                      │  via OIDC discovery  │
  │                      │<─────────────────────│
  │                      │                      │
  │                      │  JWKS + issuer       │
  │                      │─────────────────────>│
  │                      │                      │
  │  API response        │                      │
  │<────────────────────────────────────────────│

The frontend initializes Keycloak with PKCE (S256):

// frontend/src/lib/keycloak.ts
export async function initKeycloak() {
  const kc = getKeycloak();
  return kc.init({ pkceMethod: "S256" });
}

The API validates tokens using .NET's JWT Bearer middleware, pointing at Keycloak's authority URL for OIDC discovery:

// Program.cs
builder.Services.AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.Authority = keycloakSettings["Authority"];
        options.Audience = keycloakSettings["Audience"];
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            NameClaimType = "preferred_username",
            RoleClaimType = ClaimTypes.Role
        };
    });

The Repository Pattern

The API uses the repository pattern to abstract HAPI FHIR interactions. This keeps controllers thin and business logic testable:

Controller → Service → Repository → HAPI FHIR

Three repositories handle different domains:

  • HapiFhirPatientRepository -- Patient demographics, vitals, conditions, medications, labs, timeline
  • HapiFhirDashboardRepository -- Aggregate metrics and recent activity
  • HapiFhirExportRepository -- Bulk data export job management

Each repository uses the Hl7.Fhir.R4 client library to communicate with HAPI FHIR, translating between FHIR resources and application DTOs.

Middleware Pipeline

Requests pass through several middleware layers before reaching controllers:

Request
  │
  ├── Serilog Request Logging
  ├── Exception Handling Middleware
  ├── Security Headers Middleware
  ├── Rate Limiter
  ├── CORS
  ├── Authentication
  ├── Authorization
  │
  v
Controller

The exception handling middleware maps .NET exceptions to structured API errors:

var (statusCode, error) = exception switch
{
    KeyNotFoundException => (HttpStatusCode.NotFound,
        new ApiError("NOT_FOUND", exception.Message, 404)),
    ArgumentException => (HttpStatusCode.BadRequest,
        new ApiError("BAD_REQUEST", exception.Message, 400)),
    UnauthorizedAccessException => (HttpStatusCode.Unauthorized,
        new ApiError("UNAUTHORIZED", exception.Message, 401)),
    _ => (HttpStatusCode.InternalServerError,
        new ApiError("INTERNAL_ERROR", "An unexpected error occurred", 500))
};

The security headers middleware strips server fingerprinting and adds defensive headers including CSP, HSTS, and Permissions-Policy.

Network Architecture

All five services communicate on a shared Docker network (fhirhub-network). Only three ports are exposed to the host:

PortServicePurpose
5197fhirhub-apiAPI gateway
8080hapi-fhirFHIR server
8180keycloakIdentity provider

The two PostgreSQL instances have no exposed ports -- they're only accessible from within the Docker network. This is intentional. In production, you'd also remove the HAPI FHIR and Keycloak port mappings and route everything through the API.

What's Next

In Part 3, we'll get the entire stack running locally with a single command and verify each service is healthy.


_Find the source code on GitHub_ _Connect on LinkedIn_

Building FhirHub — Part 2 of 19

Related Projects

Featured

FhirHub

An open-source clinical data platform built on FHIR R4 that unifies patient demographics, vitals, labs, medications, and conditions into a single interface with SMART on FHIR authentication and role-based access.

Next.js
React
TypeScript
.NET 8
+9
Featured

InteropNimbus

A healthcare interoperability monitoring dashboard for Mirth Connect and HAPI FHIR. Provides real-time channel health, message tracing, and FHIR gateway visibility with enterprise SSO via Keycloak.

React 19
TypeScript
Vite 7
Tailwind CSS v4
+6