Deployment Architecture
graph TD
User[End User] --> Client[MCP Client / Browser]
subgraph "Docker Compose Stack"
NGX["nginx (single front door, :8080 = WEB_PORT)"]
WEB["web-v2 (nginx + static Vite SPA) — /admin pages"]
Server["app (Node MCP server + admin REST API, internal :8000)"]
Worker["admin-worker (Node — imports, embeddings, drug pipeline)"]
PGB["pgbouncer (transaction mode, :5432)"]
PG["postgres:16 + pgvector"]
RD["redis:7 (:6379)"]
MN["minio (object storage, :9000)"]
PM["Prometheus metrics (:9090, localhost only)"]
end
Client -- "HTTP :8080" --> NGX
NGX -- "/mcp, /openapi.json, /tools/*, /admin/api/*, /admin/ws, /fhir-client/*, /fhir-oauth/*" --> Server
NGX -- "/admin pages + everything else" --> WEB
Server --> PGB --> PG
Server --> RD
Worker --> RD
Server --> MN
Server --> PM
Worker --> PGB
Worker --> MN
Server -. "Embeddings (query vectors)" .-> Ext[External model endpoints]
Worker -. "OCR (MinerU) / Analysis LM / Embeddings" .-> Ext
Worker -. "OCR (Indra, internal network ocr)" .-> OcrIndra["ocr-indra (CPU OCR)"]
Key considerations
- Single entry point: nginx is the only service intended for host access (
WEB_PORT, 8080 by default). app only has expose, no ports, so :8000 cannot be reached from the host.
- Data persistence: PostgreSQL and MinIO data live in Docker volumes, so the data survives container restarts and needs no re-import.
- Transport: always
streamable-http (through nginx's /mcp). It is the only transport the server offers; there is no stdio mode.
- pgBouncer transaction mode: incompatible with
LISTEN/NOTIFY and named prepared statements; the Node side uses pg's unnamed statements for compatibility.
- Data imports: triggered from the admin console and executed in the background by
admin-worker (there is no longer a standalone data-loader container). Source files are uploaded to MinIO through the admin console and fetched back when a job runs.
- MinIO: stores drug document assets (inserts / labels / pill images); the tools return time-limited presigned download links.
- External model endpoints: embeddings, OCR (MinerU), and the analysis LLM are all external HTTP services (OCR can instead use the bundled CPU OCR container
ocr-indra, which sits on an internal-only network). Embedding and analysis-LLM endpoints live in the provider-account / profile tables (admin.llm_providers, llm_routes, llm_route_members, llm_role_routes) and are configured on the console's Model endpoints page; OCR lives in the ocr group of admin.app_settings and is configured under External services. When unavailable, the system degrades (search falls back to keywords and is stamped search_mode: "keyword_only"; analysis jobs pause and retry automatically, without affecting queries).
- Legacy front-end: the Next.js
web service is kept only in the compose profile legacy-web for rollback and is not started by default.
- Images:
app (node-server/Dockerfile), admin-worker (Dockerfile.worker) and web-v2 (web-v2/Dockerfile) are three separate images; job / loader code changes must be rebuilt into admin-worker too.