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

Docker Compose for Healthcare Applications

A breakdown of FhirHub's Docker Compose setup: five services with health checks, dependency ordering, volume management, and network isolation for running a full healthcare stack locally.

D

David Le

Docker Compose for Healthcare Applications

By David Le -- Part 10 of the FhirHub Series

Running a healthcare application locally requires orchestrating multiple services -- a FHIR server, identity provider, databases, and your application code. FhirHub uses Docker Compose to define all five services in a single file with health checks, dependency ordering, and proper network isolation.

The Five Services

# FhirHubServer/docker-compose.yml
services:
  postgres:           # HAPI FHIR database
  hapi-fhir:          # FHIR R4 server
  keycloak-postgres:  # Keycloak database
  keycloak:           # Identity provider
  fhirhub-api:        # .NET 8 API gateway

Let's break down each service and the design decisions behind it.

PostgreSQL for HAPI FHIR

postgres:
  image: postgres:18-alpine
  container_name: fhirhub-postgres
  environment:
    POSTGRES_DB: hapi
    POSTGRES_USER: hapi
    POSTGRES_PASSWORD: hapi
  volumes:
    - postgres-data:/var/lib/postgresql/data
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U hapi -d hapi"]
    interval: 10s
    timeout: 5s
    retries: 5

Key points:

  • Alpine image keeps the container small (~80MB vs ~400MB for the full image)
  • Named volume (postgres-data) persists data across container restarts
  • Health check uses pg_isready which verifies the database is accepting connections
  • No exposed ports -- only accessible from within the Docker network

HAPI FHIR R4 Server

hapi-fhir:
  image: hapiproject/hapi:latest
  container_name: fhirhub-hapi
  ports:
    - "8080:8080"
  environment:
    - spring.datasource.url=jdbc:postgresql://postgres:5432/hapi
    - spring.datasource.username=hapi
    - spring.datasource.password=hapi
    - spring.datasource.driverClassName=org.postgresql.Driver
    - spring.jpa.properties.hibernate.dialect=ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect
    - hapi.fhir.default_encoding=json
    - hapi.fhir.enable_repository_validating_interceptor=false
    - hapi.fhir.fhir_version=R4
    - hapi.fhir.allow_external_references=true
    - hapi.fhir.cors.allow_credentials=true
    - hapi.fhir.cors.allowed_origin=*
  depends_on:
    postgres:
      condition: service_healthy
  healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost:8080/fhir/metadata"]
    interval: 30s
    timeout: 10s
    retries: 5
    start_period: 60s

Configuration details:

  • FHIR version R4 is explicitly set. HAPI supports multiple versions, so this ensures consistency.
  • JSON default encoding means all responses are JSON rather than XML.
  • Repository validation is disabled for development performance. In production, enable it.
  • External references allowed lets resources reference other resources across different servers.
  • Health check against /fhir/metadata verifies the FHIR server is fully operational, not just that the HTTP port is open.
  • 60-second start period gives HAPI time to run database migrations on first boot.

Separate Keycloak Database

keycloak-postgres:
  image: postgres:18-alpine
  container_name: fhirhub-keycloak-postgres
  environment:
    POSTGRES_DB: keycloak
    POSTGRES_USER: keycloak
    POSTGRES_PASSWORD: keycloak
  volumes:
    - keycloak-postgres-data:/var/lib/postgresql/data
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
    interval: 10s
    timeout: 5s
    retries: 5

Why a separate database? In healthcare, separating authentication data from clinical data is a security best practice:

  1. Blast radius -- A database compromise affects only one domain
  2. Independent scaling -- Auth and clinical workloads have different profiles
  3. Compliance -- Audit and access control data may have different retention requirements
  4. Backup strategy -- Different backup frequencies and retention policies

Keycloak Identity Provider

keycloak:
  image: quay.io/keycloak/keycloak:26.0
  container_name: fhirhub-keycloak
  command: start-dev --import-realm
  ports:
    - "8180:8080"
  environment:
    KC_DB: postgres
    KC_DB_URL: jdbc:postgresql://keycloak-postgres:5432/keycloak
    KC_DB_USERNAME: keycloak
    KC_DB_PASSWORD: keycloak
    KC_BOOTSTRAP_ADMIN_USERNAME: admin
    KC_BOOTSTRAP_ADMIN_PASSWORD: admin
  volumes:
    - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
  depends_on:
    keycloak-postgres:
      condition: service_healthy

Key details:

  • start-dev --import-realm runs Keycloak in development mode and imports the pre-configured realm on first boot
  • Port 8180 externally maps to Keycloak's internal 8080 to avoid conflicts with HAPI FHIR
  • Read-only volume mount (:ro) for the realm file prevents accidental modification
  • Health check uses a TCP socket test against the /health/ready endpoint

FhirHub API

fhirhub-api:
  build:
    context: .
    dockerfile: src/FhirHubServer.Api/Dockerfile
  container_name: fhirhub-api
  ports:
    - "5197:8080"
  environment:
    - ASPNETCORE_ENVIRONMENT=Development
    - ASPNETCORE_URLS=http://+:8080
    - HapiFhir__BaseUrl=http://hapi-fhir:8080/fhir
    - Keycloak__AdminApiBaseUrl=http://keycloak:8080
    - Keycloak__BackendClientId=fhirhub-backend
    - Keycloak__BackendClientSecret=fhirhub-backend-secret
  depends_on:
    hapi-fhir:
      condition: service_healthy

The API is the only service built from source (the others use pre-built images). It connects to both HAPI FHIR and Keycloak using internal Docker network hostnames.

Health Check Strategy

Every service has a health check tailored to its readiness:

ServiceCheck MethodWhat It Verifies
postgrespg_isreadyDatabase accepts connections
hapi-fhircurl /fhir/metadataFHIR server is operational
keycloak-postgrespg_isreadyDatabase accepts connections
keycloakTCP socket /health/readyIdentity provider is ready
fhirhub-apicurl /api/dashboard/metricsAPI is serving requests

The depends_on: condition: service_healthy directive ensures services start in the correct order and only after their dependencies are truly ready -- not just "container started."

Volume Management

volumes:
  postgres-data:
  keycloak-postgres-data:

Two named volumes persist database state:

  • postgres-data -- FHIR resources, observations, patients
  • keycloak-postgres-data -- Users, roles, realm configuration

Named volumes survive container restarts and docker-compose down. To fully reset, use docker-compose down -v.

Network Configuration

networks:
  default:
    name: fhirhub-network

All services share a single named network. Docker's built-in DNS resolves service names to container IPs, so http://hapi-fhir:8080/fhir works from any container on the network.

Only three ports are exposed to the host machine:

External PortServicePurpose
5197fhirhub-apiAPI gateway
8080hapi-fhirFHIR server (dev access)
8180keycloakIdentity provider (dev access)

The PostgreSQL instances have no exposed ports -- they're only accessible from within the Docker network.

Production Considerations

For production deployment, you'd want to:

  1. Remove exposed ports for HAPI FHIR and Keycloak
  2. Use secrets management instead of plain-text passwords
  3. Enable HTTPS on all public-facing services
  4. Add resource limits (CPU/memory) to each container
  5. Use external databases instead of containerized PostgreSQL
  6. Enable HAPI FHIR validation interceptors
  7. Switch Keycloak from start-dev to start (production mode)

What's Next

In Part 11, we'll explore the Next.js frontend architecture -- App Router with route groups, the component library, custom hooks, and DaisyUI theming.


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