01-infrastructurewave: W1filled24 citations

Full HMS Docker Compose Orchestration

Audiences: developer, internal

Full HMS Docker Compose Orchestration

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":

  1. Author a tenant-data/<trigram>/manifest.json (ports, subnet, secrets, externals).
  2. Run provision-tenant.sh manifest.json.
  3. Validate → build → migrate → seed → start.
  4. 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.sh is idempotent on re-run; destroy-tenant.sh --keep-volumes lets you blow away the runtime while keeping data; manifest-to-env.sh is the one source of truth for port assignment.

Architecture

diagram
                      ┌──────────── tenant-data/<trigram>/manifest.json ─────────────┐
                      │ trigram, network.subnet, ports.*, external.*, internal.*    │
                      └─────────────────────────────┬───────────────────────────────┘
                                                    │  scripts/manifest-to-env.sh
                                                    ▼
       docker compose -p <trigram> \
         --profile (default | seed | voice | jitsi | vivoxx) \
         -f ddx-docker-full-hms/docker-compose.yml up -d
                                                    │
   ┌────────────────────────────────────────────────┴────────────────────────────────────────┐
   │                          LAYER 1 — infrastructure (no deps)                              │
   │   postgres-main :15432   postgres-fhir :25432   redis :16379   qdrant :16333,16334       │
   │   minio :36900,36901                                                                     │
   │                                                                                          │
   │                          LAYER 2 — schema bootstrap (one-shot)                           │
   │   db-init  (prisma migrate deploy, 6 DBs)                                                │
   │                                                                                          │
   │                          LAYER 3 — applications                                          │
   │   hapi-fhir :18080  ddx-api :14100  ddx-web :36000                                       │
   │                                                                                          │
   │                          LAYER 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: or include: (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 configx-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 seed and --profile voice keep one-shot or rarely-used services out of docker compose up by 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-driventenant-data/<trigram>/manifest.json is the single source of truth. scripts/manifest-to-env.sh exports the manifest fields into the env (PORT_*, HMS_*_PORT, …). The legacy HMS_* 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.4 pin across every Dockerfile — pnpm 10's strict postinstall blocks esbuild / sharp / prisma / @swc/core / @nestjs/core and breaks the build. See CLAUDE.md §13.1.
  • RBAC_ENFORCEMENT=enforce is the compose default — the ddx-docker-full-hms compose file sets RBAC_ENFORCEMENT: ${RBAC_ENFORCEMENT:-enforce}, so a Docker deploy enforces RBAC out of the box. Only the application code-level fallback is warn (dry-run); always set the key explicitly in production. Startup MUST log RBAC enforcement active — blocking unauthorized requests with 403. See CLAUDE.md §13.2.

Data Flow

Provisioning a new tenant (provision-tenant.sh)

  1. Validatescripts/validate-manifest.sh runs ajv against manifest.schema.json, plus a jq check that no two tenants share a port, plus a cross-check that tenant-data/<trigram>/manifest.json matches the trigram passed on the CLI.
  2. Generate secretsscripts/generate-secrets.sh writes tenant-data/<trigram>/secrets.json (gitignored). Includes JWT secrets, Postgres passwords, LiveKit keys, the gatewayApiKeyVoice (AC-7 contract).
  3. Export envscripts/manifest-to-env.sh translates manifest.ports.*PORT_* + HMS_*_PORT env vars in the current shell.
  4. Builddocker compose -p <trigram> build (api/web/seeder/flowagent/db-init/vivoxx Dockerfiles under dockerfiles/).
  5. Bring up infra + appsdocker compose -p <trigram> up -d starts Layer 1 then Layer 3 once health-checks pass.
  6. Migratedb-init container runs prisma migrate deploy against 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.
  7. Seed (optional) — --profile seed one-shot ddx-seeder writes initial users, clinic config, clinical taxonomies.
  8. Profile-gated extras — voice / jitsi / vivoxx if requested.
  9. Traefik (already running) picks up the labels on ddx-web and serves <trigram>.dudoxx.com.

Critical contracts (must hold at every deploy)

  • AC-7 key parityddx-flowagent.DDX_API_KEY MUST equal ddx-api.GATEWAY_API_KEY_VOICE. Both injected from secrets.json.gatewayApiKeyVoice. A mismatch silently disables voice.
  • Next.js build envddx-web NODE_ENV=production MUST be set at build time. In the full-hms Docker build the web Dockerfile still uses the env-aware .next_prod distDir (dockerfiles/web/Dockerfile:77,151,162), so PM2/runtime serves .next_prod. NOTE: the standalone dev build flow was reverted to a single .next output on 2026-06-04 (root CLAUDE.md §"ddx-web Build Flow") — the full-hms Dockerfile has not yet been migrated to match. Treat .next_prod as full-hms-Docker-only; do not assume it in the local dev tree.
  • flowagent platform pinlinux/amd64 (onnxruntime-node native binary).
  • Seeder default passwordDEFAULT_PASSWORD env (default Dudoxx123!) governs all seeded users. The password field inside tenant-data JSON is documentation only.
  • Patient verification — seeded patients have emailVerified=NULL and phoneVerified=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 canonical TENANT_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:76postgres-main service (6 logical databases, Postgres 16-alpine).
  • ddx-docker-full-hms/docker-compose.yml:84TENANT_TRIGRAM: ${TENANT_TRIGRAM:?...} — fail-fast if trigram missing.
  • ddx-docker-full-hms/docker-compose.yml:107com.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:113postgres-fhir dedicated HAPI FHIR Postgres.
  • ddx-docker-full-hms/docker-compose.yml:148redis Redis 7-alpine.
  • ddx-docker-full-hms/docker-compose.yml:177qdrant Qdrant v1.12.5 (full-hms pin; the standalone dev stack pins v1.16.3 — versions intentionally diverge per deployment target).
  • ddx-docker-full-hms/docker-compose.yml:205minio object storage.
  • ddx-docker-full-hms/docker-compose.yml:299ddx-seeder (one-shot, profile seed).
  • ddx-docker-full-hms/docker-compose.yml:334hapi-fhir (image dudoxx/hapi-fhir:latest).
  • ddx-docker-full-hms/docker-compose.yml:406ddx-api NestJS service (image dudoxx/ddx-api:latest).
  • ddx-docker-full-hms/docker-compose.yml:444HAPI_FHIR_BASE_URL: http://hapi-fhir:8080/fhir — internal hostname resolution.
  • ddx-docker-full-hms/docker-compose.yml:472MINIO_ENDPOINT: http://minio:9000 — internal MinIO S3 endpoint.
  • ddx-docker-full-hms/docker-compose.yml:539JITSI_BASE_URL: http://jitsi-web:80 — cross-service URL passed to ddx-api.
  • ddx-docker-full-hms/docker-compose.yml:694ddx-web Next.js service.
  • ddx-docker-full-hms/docker-compose.yml:719API_URL: http://ddx-api:4100/api/v1 (web → api server-side fetch).
  • ddx-docker-full-hms/docker-compose.yml:831jitsi-web (profile jitsi).
  • ddx-docker-full-hms/docker-compose.yml:1081ddx-flowagent voice agent (profile voice).
  • ddx-docker-full-hms/docker-compose.yml:1105DDX_API_URL: http://ddx-api:4100/api/v1 (flowagent → api, AC-7 contract).
  • ddx-docker-full-hms/docker-compose.yml:1228 — per-tenant default bridge subnet: ${TENANT_SUBNET:-10.100.0.0/16}.
  • ddx-docker-full-hms/docker-compose.yml:1236 — shared traefik-public network external: true.
  • ddx-docker-full-hms/docker-compose.yml:1245volumes: 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 mandatorydocker compose -p <trigram> is the namespacing primitive. Forgetting it (just docker compose up) drops everything into the default ddx-docker-full-hms_* namespace and BREAKS multi-tenant isolation. Compose will refuse to start without TENANT_TRIGRAM set (line 84).
  • Host port collisions between tenants are the #1 first-time failure — validate-manifest.sh catches this. Always run it before provision-tenant.sh.
  • Profile flags are additive: --profile seed --profile voice --profile jitsi starts all three; default alone is the application baseline. provision-tenant.sh picks 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-init container depends on postgres-main healthy but NOT on hapi-fhir; if you see db-init exit 1 immediately, it's almost always postgres-main failing its own health-check (check the pg_hba.conf mount on line 95).
  • Volume reset is irreversibledestroy-tenant.sh <trigram> --yes (without --keep-volumes) wipes Postgres, Redis, Qdrant, MinIO for that tenant.
  • secrets.json is gitignored — never commit. generate-secrets.sh is the only sanctioned producer.
  • Backups: per-tenant postgres_main_data, postgres_fhir_data, minio_data are the load-bearing volumes. Snapshot on the same schedule.
  • Subnet collisions across tenants are not enforced by Docker — validate-manifest.sh MUST check manifest.network.subnet uniqueness 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