Full HMS Docker Compose Orchestration
Audiences: developer, internal
One ~1272-line parameterized compose file + a manifest-driven provisioner that brings up a fully isolated HMS tenant — Postgres × 2 (
postgres-main+postgres-fhir), Redis, Qdrant, MinIO, HAPI FHIR, ddx-api, ddx-web, db-init, optional seeder/flowagent/jitsi — with one command.
Deployment pattern: DOCKER-FULL-HMS (manifest-driven, multi-tenant docker compose -p <trigram>; ports from manifest.ports.*). Distinct from the DEV stack (.env.docker, single-tenant, host ports in the 6xxx band on ddx-001-* containers). Key nuance: full-hms keeps a dedicated postgres-fhir container (:25432, ddx_fhir_core) — the two-container Postgres model. The standalone dev stack was consolidated to a SINGLE ddx-001-postgres-1 (:6432) that also hosts ddx_fhir_core (see infra-postgres). Both are current, for different deployment targets.
Business Purpose
ddx-docker-full-hms is the single deployable unit of Dudoxx HMS. It is what turns "a clinic signed up Monday" into "the clinic is live Tuesday":
- Author a
tenant-data/<trigram>/manifest.json(ports, subnet, secrets, externals). - Run
provision-tenant.sh manifest.json. - Validate → build → migrate → seed → start.
- Traefik (already running, separate stack) routes the new subdomain.
This umbrella is what makes the multi-tenant story economically viable:
- No per-tenant DevOps work — port allocation, subnet allocation, secret generation, migration order, seed order are all script-driven.
- Total isolation — each tenant is its own Docker Compose project (
-p <trigram>); volumes, networks, container names are auto-namespaced. A bug in tenant A's seeder cannot touch tenant B's database. - Standardised contract — every tenant runs the same images, same migration set, same RBAC enforcement mode. There is no "snowflake clinic".
Revenue lever: cost-to-onboard a new tenant approaches the cost of writing a 30-line JSON manifest. This is the operational moat behind the Dudoxx multi-tenant SaaS pricing.
Audiences
- Investor: tenant unit-economics depend on this file being the only thing that runs per-tenant. The single-compose + manifest + provisioner architecture is what lets us promise a same-day onboarding SLA.
- Clinical buyer (doctor/nurse/receptionist): invisible — they get a working subdomain. The page they land on (
{trigram}.dudoxx.com) is the only contact point. - Developer/partner: profile-driven (
--profile seed | voice | jitsi | vivoxx) — start the bits you need; the rest stays dormant. All host ports are parameterised via env vars (PORT_API,PORT_WEB, …). - Internal (ops/support):
provision-tenant.shis idempotent on re-run;destroy-tenant.sh --keep-volumeslets you blow away the runtime while keeping data;manifest-to-env.shis the one source of truth for port assignment.
Architecture
The manifest drives env generation, which drives one docker compose invocation that
brings up four dependency layers:
| # | Layer | Services (host ports are the compose band, NOT the 6xxx dev band) |
|---|---|---|
| 1 | infrastructure (no deps) | postgres-main :15432 · postgres-fhir :25432 · redis :16379 · qdrant :16333,16334 · minio :36900,36901 |
| 2 | schema bootstrap (one-shot) | db-init (prisma migrate deploy, 6 DBs) |
| 3 | applications | hapi-fhir :18080 · ddx-api :14100 · ddx-web :36000 |
| 4 | profile-gated extras | ddx-seeder (--profile seed, one-shot) · ddx-flowagent (--profile voice) · jitsi-{web,prosody,jicofo,jvb,jibri} (--profile jitsi) · ddx-vivoxx-headtts (--profile vivoxx) |
External (shared across tenants, NOT in this compose): Deepgram on-prem, LiteLLM router, TTS (the compose still wires KOKORO_TTS_ENDPOINT at docker-compose.yml:501, but Kokoro is deprecated — DDX CUDA TTS is canonical now, memory [[feedback-kokoro-tts-deprecated]]), Traefik edge. Their URLs come from manifest.external.*.
For the database layout see infra-postgres (6 main DBs + 1 FHIR DB per tenant). For the routing layer see infra-traefik. For the LLM gateway see infra-litellm.
Tech Stack & Choices
- Single ~1272-line compose file — kept monolithic on purpose. Splitting per-service requires
extends:orinclude:(compose 2.20+) which both add cognitive load. One file with anchors (<<: *common-restart,<<: *common-healthcheck) is auditable end-to-end. - YAML anchors for cross-cutting config —
x-common-healthcheck,x-common-restart,x-common-labels,x-logging,x-node-env. Change once, applies to all services. - Compose profiles as the on/off switch —
--profile seedand--profile voicekeep one-shot or rarely-used services out ofdocker compose upby default. - Per-tenant Docker Compose project (
-p <trigram>) as the isolation primitive — gives auto-namespacing of containers (<trigram>_postgres-main_1), volumes (<trigram>_postgres_main_data), and networks. Beats--project-directory+ custom prefixing. - Manifest-driven, not env-driven —
tenant-data/<trigram>/manifest.jsonis the single source of truth.scripts/manifest-to-env.shexports the manifest fields into the env (PORT_*,HMS_*_PORT, …). The legacyHMS_*env names still work as fallbacks (${PORT_API:-${HMS_API_PORT:-14100}}) — backwards-compat for older tooling. - Per-tenant HAPI FHIR instead of one big FHIR with partitions — partitions add coordination overhead and a tenant compromise window. One Hibernate per tenant is more containers but zero cross-tenant blast radius.
pnpm@9.15.4pin across every Dockerfile — pnpm 10's strict postinstall blocksesbuild/sharp/prisma/@swc/core/@nestjs/coreand breaks the build. SeeCLAUDE.md§13.1.RBAC_ENFORCEMENT=enforceis the compose default — theddx-docker-full-hmscompose file setsRBAC_ENFORCEMENT: ${RBAC_ENFORCEMENT:-enforce}, so a Docker deploy enforces RBAC out of the box. Only the application code-level fallback iswarn(dry-run); always set the key explicitly in production. Startup MUST logRBAC enforcement active — blocking unauthorized requests with 403. SeeCLAUDE.md§13.2.
Data Flow
Provisioning a new tenant (provision-tenant.sh)
- Validate —
scripts/validate-manifest.shrunsajvagainstmanifest.schema.json, plus ajqcheck that no two tenants share a port, plus a cross-check thattenant-data/<trigram>/manifest.jsonmatches the trigram passed on the CLI. - Generate secrets —
scripts/generate-secrets.shwritestenant-data/<trigram>/secrets.json(gitignored). Includes JWT secrets, Postgres passwords, LiveKit keys, thegatewayApiKeyVoice(AC-7 contract). - Export env —
scripts/manifest-to-env.shtranslatesmanifest.ports.*→PORT_*+HMS_*_PORTenv vars in the current shell. - Build —
docker compose -p <trigram> build(api/web/seeder/flowagent/db-init/vivoxx Dockerfiles underdockerfiles/). - Bring up infra + apps —
docker compose -p <trigram> up -dstarts Layer 1 then Layer 3 once health-checks pass. - Migrate —
db-initcontainer runsprisma migrate deployagainst all 6 main DBs (ddx_api_main,ddx_api_log,ddx_api_ai,ddx_api_com,ddx_api_acc,ddx_ext_cal); HAPI FHIR auto-migrates its own schema on startup. - Seed (optional) —
--profile seedone-shotddx-seederwrites initial users, clinic config, clinical taxonomies. - Profile-gated extras — voice / jitsi / vivoxx if requested.
- Traefik (already running) picks up the labels on
ddx-weband serves<trigram>.dudoxx.com.
Critical contracts (must hold at every deploy)
- AC-7 key parity —
ddx-flowagent.DDX_API_KEYMUST equalddx-api.GATEWAY_API_KEY_VOICE. Both injected fromsecrets.json.gatewayApiKeyVoice. A mismatch silently disables voice. - Next.js build env —
ddx-web NODE_ENV=productionMUST be set at build time. In the full-hms Docker build the web Dockerfile still uses the env-aware.next_proddistDir (dockerfiles/web/Dockerfile:77,151,162), so PM2/runtime serves.next_prod. NOTE: the standalone dev build flow was reverted to a single.nextoutput on 2026-06-04 (root CLAUDE.md §"ddx-web Build Flow") — the full-hms Dockerfile has not yet been migrated to match. Treat.next_prodas full-hms-Docker-only; do not assume it in the local dev tree. - flowagent platform pin —
linux/amd64(onnxruntime-node native binary). - Seeder default password —
DEFAULT_PASSWORDenv (defaultDudoxx123!) governs all seeded users. Thepasswordfield inside tenant-data JSON is documentation only. - Patient verification — seeded patients have
emailVerified=NULLandphoneVerified=NULL. Run post-seed SQL to flip both before login probes. - Trusted Gateway headers — RBAC tests must forward
X-Gateway-User-{ID,Email,Role,FHIR-Patient-ID}. A direct curl without headers tests the service-account context (SUPER_ADMIN), not the user.
Implicated Code
MANDATORY: ≥3 file:line citations.
ddx-docker-full-hms/docker-compose.yml:5— usage banner showing the canonicalTENANT_TRIGRAM=<x> docker compose -p ${TENANT_TRIGRAM} --profile full up -d.ddx-docker-full-hms/docker-compose.yml:43— YAML anchors (x-common-healthcheck,x-common-restart,x-common-labels,x-logging,x-node-env).ddx-docker-full-hms/docker-compose.yml:76—postgres-mainservice (6 logical databases, Postgres 16-alpine).ddx-docker-full-hms/docker-compose.yml:84—TENANT_TRIGRAM: ${TENANT_TRIGRAM:?...}— fail-fast if trigram missing.ddx-docker-full-hms/docker-compose.yml:107—com.dudoxx.databases: ddx_api_main,ddx_api_log,ddx_api_ai,ddx_api_com,ddx_api_acc,ddx_ext_cal(6-DB inventory label).ddx-docker-full-hms/docker-compose.yml:113—postgres-fhirdedicated HAPI FHIR Postgres.ddx-docker-full-hms/docker-compose.yml:148—redisRedis 7-alpine.ddx-docker-full-hms/docker-compose.yml:177—qdrantQdrantv1.12.5(full-hms pin; the standalone dev stack pinsv1.16.3— versions intentionally diverge per deployment target).ddx-docker-full-hms/docker-compose.yml:205—minioobject storage.ddx-docker-full-hms/docker-compose.yml:299—ddx-seeder(one-shot, profileseed).ddx-docker-full-hms/docker-compose.yml:334—hapi-fhir(imagedudoxx/hapi-fhir:latest).ddx-docker-full-hms/docker-compose.yml:406—ddx-apiNestJS service (imagedudoxx/ddx-api:latest).ddx-docker-full-hms/docker-compose.yml:444—HAPI_FHIR_BASE_URL: http://hapi-fhir:8080/fhir— internal hostname resolution.ddx-docker-full-hms/docker-compose.yml:472—MINIO_ENDPOINT: http://minio:9000— internal MinIO S3 endpoint.ddx-docker-full-hms/docker-compose.yml:539—JITSI_BASE_URL: http://jitsi-web:80— cross-service URL passed to ddx-api.ddx-docker-full-hms/docker-compose.yml:694—ddx-webNext.js service.ddx-docker-full-hms/docker-compose.yml:719—API_URL: http://ddx-api:4100/api/v1(web → api server-side fetch).ddx-docker-full-hms/docker-compose.yml:831—jitsi-web(profilejitsi).ddx-docker-full-hms/docker-compose.yml:1081—ddx-flowagentvoice agent (profilevoice).ddx-docker-full-hms/docker-compose.yml:1105—DDX_API_URL: http://ddx-api:4100/api/v1(flowagent → api, AC-7 contract).ddx-docker-full-hms/docker-compose.yml:1228— per-tenantdefaultbridgesubnet: ${TENANT_SUBNET:-10.100.0.0/16}.ddx-docker-full-hms/docker-compose.yml:1236— sharedtraefik-publicnetworkexternal: true.ddx-docker-full-hms/docker-compose.yml:1245—volumes:block (postgres_main, postgres_fhir, redis, qdrant, minio, jitsi_*, livekit-rendered-config).ddx-docker-full-hms/CLAUDE.md— full operator runbook, profile matrix, critical contracts table, troubleshooting.
Operational Notes
- Project name is mandatory —
docker compose -p <trigram>is the namespacing primitive. Forgetting it (justdocker compose up) drops everything into the defaultddx-docker-full-hms_*namespace and BREAKS multi-tenant isolation. Compose will refuse to start withoutTENANT_TRIGRAMset (line 84). - Host port collisions between tenants are the #1 first-time failure —
validate-manifest.shcatches this. Always run it beforeprovision-tenant.sh. - Profile flags are additive:
--profile seed --profile voice --profile jitsistarts all three;defaultalone is the application baseline.provision-tenant.shpicks profiles automatically from manifest flags (e.g.internal.flowagent: true→--profile voice). - Health-check timing — HAPI FHIR Hibernate schema bootstrap takes 2–3 minutes on first run. The
db-initcontainer depends onpostgres-mainhealthy but NOT onhapi-fhir; if you seedb-initexit 1 immediately, it's almost alwayspostgres-mainfailing its own health-check (check thepg_hba.confmount on line 95). - Volume reset is irreversible —
destroy-tenant.sh <trigram> --yes(without--keep-volumes) wipes Postgres, Redis, Qdrant, MinIO for that tenant. secrets.jsonis gitignored — never commit.generate-secrets.shis the only sanctioned producer.- Backups: per-tenant
postgres_main_data,postgres_fhir_data,minio_dataare the load-bearing volumes. Snapshot on the same schedule. - Subnet collisions across tenants are not enforced by Docker —
validate-manifest.shMUST checkmanifest.network.subnetuniqueness before bringing a new tenant up. - Validation:
bash
docker compose -p <trigram> ps curl http://localhost:<api-port>/api/v1/health # API health curl http://localhost:<fhir-port>/fhir/metadata # HAPI metadata docker compose -p <trigram> exec postgres-main pg_isready -U dudoxx_user
Related Topics
- infra-postgres — the 6+1 DB layout brought up by Layer 1
- infra-redis — sessions / BullMQ backend
- infra-minio — per-tenant S3 buckets
- infra-qdrant — vector store for TUCAN
- infra-livekit — RTC for voice (profile
voice) - infra-jitsi — video conferencing (profile
jitsi) - infra-litellm — external LLM gateway referenced by
manifest.external.litellmUrl - infra-traefik — edge proxy that fronts
<trigram>.dudoxx.com - CLAUDE_DATABASE — schema-level detail on the 5+1 Prisma DBs
- CLAUDE_DEPLOYMENT_STT_ENGINE — companion deployment guide for STT engine