first commit

This commit is contained in:
2026-03-16 00:02:58 +06:00
commit c11f0bd5bc
36 changed files with 11938 additions and 0 deletions

466
docker-compose.yml Normal file
View File

@@ -0,0 +1,466 @@
# =============================================================================
# BD FHIR National — Production docker-compose.yml
#
# USAGE:
# # First deploy:
# docker-compose --env-file .env up -d
#
# # Scale HAPI replicas (pilot: 1, production: 3):
# docker-compose --env-file .env up -d --scale hapi=3
#
# # Pull updated image and redeploy zero-downtime:
# docker-compose --env-file .env pull hapi
# docker-compose --env-file .env up -d --no-deps --scale hapi=3 hapi
#
# # View logs:
# docker-compose logs -f hapi
#
# REQUIRED: .env file in same directory as this file.
# Copy .env.example to .env and fill in all values before first deploy.
# NEVER commit .env to version control.
#
# =============================================================================
# SCALING ROADMAP
# =============================================================================
#
# PHASE 1 — Pilot (<50 vendors, <10,000 resources/day)
# hapi replicas: 1
# postgres-fhir: 1 instance, no replication
# postgres-audit: 1 instance, no replication
# pgbouncer: 1 instance
# Expected load: ~0.1 req/s average, ~5 req/s burst
# This docker-compose file as written.
#
# PHASE 2 — Regional rollout (<500 vendors, <100,000 resources/day)
# hapi replicas: 3 (--scale hapi=3, no other changes needed)
# postgres-fhir: Add streaming replication replica for read queries.
# Change: add postgres-fhir-replica service,
# configure HAPI read datasource to replica.
# postgres-audit: Add streaming replication replica.
# pgbouncer: Scale to 2 instances behind a VIP.
# nginx: Already stateless. Add second nginx instance.
# Session storage: Add Redis for distributed JWKS cache
# (currently per-replica in-memory — acceptable at Phase 1).
# Changes needed: Add postgres-fhir-replica, postgres-audit-replica,
# redis services. Update HAPI datasource config.
# Add pgBouncer VIP (HAProxy or keepalived).
#
# PHASE 3 — National rollout (>500 vendors, >1,000,000 resources/day)
# Move to Kubernetes (K8s) or Docker Swarm.
# docker-compose is not the right orchestrator at this scale.
# Kubernetes equivalents:
# hapi → Deployment with HPA (autoscale on CPU/RPS)
# postgres-fhir → Patroni cluster (HA PostgreSQL)
# postgres-audit → Patroni cluster or managed RDS equivalent
# pgbouncer → PgBouncer in K8s sidecar or pgBouncer-as-a-service
# nginx → Ingress controller (nginx-ingress or Traefik)
# At this phase, partition HAPI JPA tables (see V1 migration comments).
# Estimated trigger: 5M total resources in HFJ_RESOURCE.
#
# =============================================================================
version: "3.9"
# =============================================================================
# NETWORKS
# Isolate services: only nginx is reachable from outside.
# hapi is not directly reachable — only via nginx.
# postgres services are not reachable from nginx — only from hapi/pgbouncer.
# =============================================================================
networks:
# Frontend: nginx ↔ hapi
frontend:
driver: bridge
ipam:
config:
- subnet: 172.20.1.0/24
# Backend-fhir: hapi ↔ pgbouncer-fhir ↔ postgres-fhir
backend-fhir:
driver: bridge
internal: true # no external internet access
ipam:
config:
- subnet: 172.20.2.0/24
# Backend-audit: hapi ↔ pgbouncer-audit ↔ postgres-audit
backend-audit:
driver: bridge
internal: true
ipam:
config:
- subnet: 172.20.3.0/24
# =============================================================================
# VOLUMES
# Named volumes survive container restarts and image upgrades.
# Never use bind mounts for database data in production.
# =============================================================================
volumes:
postgres-fhir-data:
driver: local
postgres-audit-data:
driver: local
hapi-logs:
driver: local
# =============================================================================
# SERVICES
# =============================================================================
services:
# ---------------------------------------------------------------------------
# postgres-fhir
# HAPI JPA store. Contains all FHIR resources.
# Read-write datasource for HAPI.
# ---------------------------------------------------------------------------
postgres-fhir:
image: postgres:15-alpine
container_name: bd-postgres-fhir
restart: unless-stopped
networks:
- backend-fhir
volumes:
- postgres-fhir-data:/var/lib/postgresql/data
# Custom postgresql.conf tuned for HAPI workload
- ./postgres/fhir/postgresql.conf:/etc/postgresql/postgresql.conf:ro
# Init script: create application user with limited privileges
- ./postgres/fhir/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
POSTGRES_DB: ${FHIR_DB_NAME}
POSTGRES_USER: ${FHIR_DB_SUPERUSER}
POSTGRES_PASSWORD: ${FHIR_DB_SUPERUSER_PASSWORD}
command: postgres -c config_file=/etc/postgresql/postgresql.conf
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${FHIR_DB_SUPERUSER} -d ${FHIR_DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# Resource limits — PostgreSQL should not starve HAPI of memory
deploy:
resources:
limits:
memory: 2G
reservations:
memory: 512M
# Do NOT expose port 5432 to host — only accessible via backend-fhir network
# If you need psql access for maintenance, use:
# docker exec -it bd-postgres-fhir psql -U ${FHIR_DB_SUPERUSER} -d ${FHIR_DB_NAME}
# ---------------------------------------------------------------------------
# postgres-audit
# Audit store. Contains audit_events and fhir_rejected_submissions.
# INSERT-only datasource for HAPI (audit_writer role).
# Completely separate from FHIR store — different container, different volume.
# ---------------------------------------------------------------------------
postgres-audit:
image: postgres:15-alpine
container_name: bd-postgres-audit
restart: unless-stopped
networks:
- backend-audit
volumes:
- postgres-audit-data:/var/lib/postgresql/data
- ./postgres/audit/postgresql.conf:/etc/postgresql/postgresql.conf:ro
- ./postgres/audit/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
POSTGRES_DB: ${AUDIT_DB_NAME}
POSTGRES_USER: ${AUDIT_DB_SUPERUSER}
POSTGRES_PASSWORD: ${AUDIT_DB_SUPERUSER_PASSWORD}
command: postgres -c config_file=/etc/postgresql/postgresql.conf
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${AUDIT_DB_SUPERUSER} -d ${AUDIT_DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
memory: 1G
reservations:
memory: 256M
# ---------------------------------------------------------------------------
# pgbouncer-fhir
# Connection pool between HAPI and postgres-fhir.
# Session mode — required for Hibernate prepared statements.
# pool_size=20: at 3 HAPI replicas with HikariCP maxPool=5,
# max PostgreSQL connections = 15. pool_size=20 gives 5 headroom.
# ---------------------------------------------------------------------------
pgbouncer-fhir:
image: bitnami/pgbouncer:1.22.1
container_name: bd-pgbouncer-fhir
restart: unless-stopped
networks:
- backend-fhir
environment:
POSTGRESQL_HOST: postgres-fhir
POSTGRESQL_PORT: "5432"
POSTGRESQL_DATABASE: ${FHIR_DB_NAME}
POSTGRESQL_USERNAME: ${FHIR_DB_APP_USER}
POSTGRESQL_PASSWORD: ${FHIR_DB_APP_PASSWORD}
PGBOUNCER_DATABASE: ${FHIR_DB_NAME}
PGBOUNCER_POOL_MODE: session
PGBOUNCER_MAX_CLIENT_CONN: "100"
PGBOUNCER_DEFAULT_POOL_SIZE: "20"
PGBOUNCER_MIN_POOL_SIZE: "5"
PGBOUNCER_RESERVE_POOL_SIZE: "5"
PGBOUNCER_RESERVE_POOL_TIMEOUT: "5"
PGBOUNCER_SERVER_IDLE_TIMEOUT: "600"
PGBOUNCER_CLIENT_IDLE_TIMEOUT: "60"
# Logging — errors and connections only, not queries (query logging
# would log patient data to container stdout)
PGBOUNCER_LOG_CONNECTIONS: "1"
PGBOUNCER_LOG_DISCONNECTIONS: "1"
PGBOUNCER_LOG_POOLER_ERRORS: "1"
PGBOUNCER_VERBOSE: "0"
depends_on:
postgres-fhir:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -h localhost -p 5432 -U ${FHIR_DB_APP_USER}"]
interval: 10s
timeout: 5s
retries: 3
# ---------------------------------------------------------------------------
# pgbouncer-audit
# Connection pool between HAPI and postgres-audit.
# Smaller pool — audit writes are async and lower volume than FHIR writes.
# ---------------------------------------------------------------------------
pgbouncer-audit:
image: bitnami/pgbouncer:1.22.1
container_name: bd-pgbouncer-audit
restart: unless-stopped
networks:
- backend-audit
environment:
POSTGRESQL_HOST: postgres-audit
POSTGRESQL_PORT: "5432"
POSTGRESQL_DATABASE: ${AUDIT_DB_NAME}
POSTGRESQL_USERNAME: ${AUDIT_DB_WRITER_USER}
POSTGRESQL_PASSWORD: ${AUDIT_DB_WRITER_PASSWORD}
PGBOUNCER_DATABASE: ${AUDIT_DB_NAME}
PGBOUNCER_POOL_MODE: session
PGBOUNCER_MAX_CLIENT_CONN: "50"
PGBOUNCER_DEFAULT_POOL_SIZE: "10"
PGBOUNCER_MIN_POOL_SIZE: "2"
PGBOUNCER_RESERVE_POOL_SIZE: "2"
PGBOUNCER_SERVER_IDLE_TIMEOUT: "600"
PGBOUNCER_LOG_CONNECTIONS: "1"
PGBOUNCER_LOG_DISCONNECTIONS: "1"
PGBOUNCER_LOG_POOLER_ERRORS: "1"
PGBOUNCER_VERBOSE: "0"
depends_on:
postgres-audit:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pg_isready -h localhost -p 5432 -U ${AUDIT_DB_WRITER_USER}"]
interval: 10s
timeout: 5s
retries: 3
# ---------------------------------------------------------------------------
# hapi
# BD FHIR National HAPI overlay.
# Stateless — no local state, all state in PostgreSQL.
# Scale with: docker-compose up -d --scale hapi=3
#
# REPLICA SCALING NOTE:
# When scaling to N replicas, ensure:
# 1. pgbouncer-fhir pool_size >= N * HAPI_DB_POOL_SIZE (default: N*5)
# 2. pgbouncer-audit pool_size >= N * HAPI_AUDIT_POOL_SIZE (default: N*2)
# 3. nginx upstream hapi has all N replica IPs or uses DNS round-robin
# (see nginx.conf — uses Docker DNS service name which auto-discovers
# all replicas when using --scale)
# ---------------------------------------------------------------------------
hapi:
image: ${HAPI_IMAGE}
# container_name intentionally omitted — docker-compose appends _1, _2, _3
# when scaling. A fixed container_name breaks --scale.
restart: unless-stopped
networks:
- frontend
- backend-fhir
- backend-audit
volumes:
- hapi-logs:/app/logs
environment:
# Spring
SPRING_PROFILES_ACTIVE: prod
# FHIR datasource — routes through pgBouncer
SPRING_DATASOURCE_URL: jdbc:postgresql://pgbouncer-fhir:5432/${FHIR_DB_NAME}
SPRING_DATASOURCE_USERNAME: ${FHIR_DB_APP_USER}
SPRING_DATASOURCE_PASSWORD: ${FHIR_DB_APP_PASSWORD}
SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver
# HikariCP — FHIR datasource pool
# 5 connections per replica × N replicas = N*5 total PostgreSQL connections
# At 3 replicas: 15 connections → fits in pgBouncer pool_size=20
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE: "5"
SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE: "2"
SPRING_DATASOURCE_HIKARI_CONNECTION_TIMEOUT: "30000"
SPRING_DATASOURCE_HIKARI_IDLE_TIMEOUT: "600000"
SPRING_DATASOURCE_HIKARI_MAX_LIFETIME: "1800000"
SPRING_DATASOURCE_HIKARI_POOL_NAME: fhir-pool
# pgBouncer session mode: prepared statements work.
# Keep this false for compatibility — pgBouncer manages statement lifecycle.
SPRING_DATASOURCE_HIKARI_DATA_SOURCE_PROPERTIES_PREPARESTATEMENT: "false"
# Audit datasource — INSERT-only, routes through pgBouncer
AUDIT_DATASOURCE_URL: jdbc:postgresql://pgbouncer-audit:5432/${AUDIT_DB_NAME}
AUDIT_DATASOURCE_USERNAME: ${AUDIT_DB_WRITER_USER}
AUDIT_DATASOURCE_PASSWORD: ${AUDIT_DB_WRITER_PASSWORD}
# HikariCP — audit datasource pool
# Smaller pool — audit writes are async
AUDIT_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE: "2"
AUDIT_DATASOURCE_HIKARI_MINIMUM_IDLE: "1"
AUDIT_DATASOURCE_HIKARI_POOL_NAME: audit-pool
# Flyway — FHIR schema migrations
SPRING_FLYWAY_URL: jdbc:postgresql://postgres-fhir:5432/${FHIR_DB_NAME}
SPRING_FLYWAY_USER: ${FHIR_DB_SUPERUSER}
SPRING_FLYWAY_PASSWORD: ${FHIR_DB_SUPERUSER_PASSWORD}
# Flyway connects directly to PostgreSQL (bypassing pgBouncer) for
# migrations — pgBouncer session mode is incompatible with DDL in
# some edge cases. Direct connection is safer for schema changes.
# Flyway — Audit schema migrations (separate datasource)
AUDIT_FLYWAY_URL: jdbc:postgresql://postgres-audit:5432/${AUDIT_DB_NAME}
AUDIT_FLYWAY_USER: ${AUDIT_DB_SUPERUSER}
AUDIT_FLYWAY_PASSWORD: ${AUDIT_DB_SUPERUSER_PASSWORD}
# HAPI FHIR
HAPI_FHIR_SERVER_ADDRESS: https://fhir.dghs.gov.bd/fhir
HAPI_FHIR_FHIR_VERSION: R4
# OCL terminology service
HAPI_OCL_BASE_URL: https://tr.ocl.dghs.gov.bd/api/fhir
HAPI_OCL_TIMEOUT_SECONDS: "10"
HAPI_OCL_RETRY_ATTEMPTS: "2"
# Cluster validator
HAPI_CLUSTER_VALIDATOR_URL: https://icd11.dghs.gov.bd/cluster/validate
HAPI_CLUSTER_VALIDATOR_TIMEOUT_SECONDS: "10"
# Keycloak
KEYCLOAK_ISSUER: https://auth.dghs.gov.bd/realms/hris
KEYCLOAK_JWKS_URL: https://auth.dghs.gov.bd/realms/hris/protocol/openid-connect/certs
KEYCLOAK_REQUIRED_ROLE: mci-api
KEYCLOAK_ADMIN_ROLE: fhir-admin
# JWKS cache: 1 hour TTL, re-fetch on unknown kid
KEYCLOAK_JWKS_CACHE_TTL_SECONDS: "3600"
# BD Core IG
HAPI_IG_PACKAGE_CLASSPATH: classpath:packages/bd.gov.dghs.core-0.2.1.tgz
HAPI_IG_VERSION: 0.2.1
# Terminology cache
HAPI_TERMINOLOGY_CACHE_TTL_SECONDS: "86400"
# JVM options — override defaults from Dockerfile
JAVA_OPTS: >-
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
-Djava.security.egd=file:/dev/urandom
-Dfile.encoding=UTF-8
-Duser.timezone=UTC
-Dspring.profiles.active=prod
# Logging
LOGGING_LEVEL_ROOT: WARN
LOGGING_LEVEL_BD_GOV_DGHS: INFO
LOGGING_LEVEL_CA_UHN_HAPI: WARN
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK: WARN
# Set to DEBUG temporarily during initial deployment verification,
# then revert to WARN. DEBUG logs contain full resource payloads.
LOGGING_LEVEL_BD_GOV_DGHS_FHIR_INTERCEPTOR: INFO
LOGGING_LEVEL_BD_GOV_DGHS_FHIR_TERMINOLOGY: INFO
LOGGING_LEVEL_BD_GOV_DGHS_FHIR_VALIDATOR: INFO
depends_on:
pgbouncer-fhir:
condition: service_healthy
pgbouncer-audit:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL",
"curl --fail --silent --show-error http://localhost:8080/actuator/health/liveness || exit 1"]
interval: 30s
timeout: 10s
start_period: 120s
retries: 3
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G
# PHASE 1: replicas=1
# PHASE 2: replicas=3 (update here or use --scale flag)
replicas: 1
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 3
window: 120s
# ---------------------------------------------------------------------------
# nginx
# Reverse proxy with TLS termination.
# Certificates managed by centralised nginx proxy — see Challenge E resolution.
# This nginx handles: upstream routing, rate limiting, request ID injection.
# ---------------------------------------------------------------------------
nginx:
image: nginx:1.25-alpine
container_name: bd-nginx
restart: unless-stopped
networks:
- frontend
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
# TLS certificates — provisioned by centralised nginx proxy / government CA
# Mount path must match ssl_certificate directives in nginx.conf
- ${TLS_CERT_PATH}:/etc/nginx/certs/server.crt:ro
- ${TLS_KEY_PATH}:/etc/nginx/certs/server.key:ro
depends_on:
hapi:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "nginx -t && curl --fail --silent http://localhost/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
# =============================================================================
# NOTES ON WHAT IS NOT IN THIS FILE
# =============================================================================
#
# ELK STACK (Elasticsearch, Logstash, Kibana):
# Not included. At pilot phase, structured JSON logs written to
# hapi-logs volume are sufficient. Ship logs to ELK via Filebeat
# agent running on the host (outside Docker) to avoid coupling
# the FHIR server uptime to the ELK stack uptime.
# Add Filebeat config in ops/ when ELK is provisioned.
#
# KEYCLOAK:
# Not included. Keycloak is an existing national service at
# https://auth.dghs.gov.bd — not deployed here.
#
# OCL TERMINOLOGY SERVER:
# Not included. External service at https://tr.ocl.dghs.gov.bd — not deployed here.
#
# CLUSTER VALIDATOR:
# Not included. External service at https://icd11.dghs.gov.bd — not deployed here.