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:
- postgres -- PostgreSQL 18 Alpine storing FHIR resources for HAPI. Health-checked with
pg_isready. - 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. - keycloak-postgres -- A separate PostgreSQL instance for Keycloak. Keeping auth data isolated from clinical data is a security best practice.
- keycloak -- Keycloak 26 running in dev mode with realm auto-import. Exposes port
8180externally,8080internally. Health-checked via TCP socket to the ready endpoint. - 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:
| Port | Service | Purpose |
|---|---|---|
| 5197 | fhirhub-api | API gateway |
| 8080 | hapi-fhir | FHIR server |
| 8180 | keycloak | Identity 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.