DL
Back to Blog
TechFebruary 2, 2026·5 min read

FhirHub Architecture Deep Dive

A deep dive into FhirHub's five-service Docker architecture. How PostgreSQL, HAPI FHIR, Keycloak, and the .NET 8 API gateway communicate, handle authentication with SMART on FHIR, and process patient data through the repository pattern.

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.

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

Related Projects

Featured

FhirHub

A healthcare data management platform built on the HL7 FHIR R4 standard, providing a comprehensive web interface for managing patient clinical data including vitals, conditions, medications, lab orders, and bulk data exports with role-based access control and full audit logging.

Next.js 16
React 19
Typescript
Tailwind CSS 4
+8