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_isreadywhich 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/metadataverifies 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:
- Blast radius -- A database compromise affects only one domain
- Independent scaling -- Auth and clinical workloads have different profiles
- Compliance -- Audit and access control data may have different retention requirements
- 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-realmruns 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/readyendpoint
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:
| Service | Check Method | What It Verifies |
|---|---|---|
| postgres | pg_isready | Database accepts connections |
| hapi-fhir | curl /fhir/metadata | FHIR server is operational |
| keycloak-postgres | pg_isready | Database accepts connections |
| keycloak | TCP socket /health/ready | Identity provider is ready |
| fhirhub-api | curl /api/dashboard/metrics | API 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 Port | Service | Purpose |
|---|---|---|
| 5197 | fhirhub-api | API gateway |
| 8080 | hapi-fhir | FHIR server (dev access) |
| 8180 | keycloak | Identity 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:
- Remove exposed ports for HAPI FHIR and Keycloak
- Use secrets management instead of plain-text passwords
- Enable HTTPS on all public-facing services
- Add resource limits (CPU/memory) to each container
- Use external databases instead of containerized PostgreSQL
- Enable HAPI FHIR validation interceptors
- Switch Keycloak from
start-devtostart(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.