services: postgres: # Postgres 16 + pgvector extension preinstalled. Required by the local # medical KB store (see db/migrations/006_pgvector_kb.sql). image: pgvector/pgvector:pg16 restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres ports: # Bound to 127.0.0.1 so a host-side `psql` / pgcli still works # but anyone scanning the public interface of a misconfigured # staging VPS can't reach Postgres with the dev `postgres/ # postgres` credentials. To open it across an internal network # set POSTGRES_HOST=0.0.0.0 in `.env` (and change the password # first). - '${POSTGRES_HOST:-127.0.0.1}:${POSTGRES_PORT:-5432}:5432' volumes: - pg-data:/var/lib/postgresql/data - ./db/init_db.sql:/docker-entrypoint-initdb.d/01-init-openrd.sql:ro healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres'] interval: 10s timeout: 5s retries: 10 kb-service: build: context: . dockerfile: apps/api/Dockerfile.kb args: # PyPI mirror for the kb image's heavy ML deps (torch above uses # its own CDN). Defaults to the Tsinghua mirror for China # deploys — pypi.org over the GFW crawls at ~17 KB/s and stalls # this build for hours. Set PIP_INDEX_URL in .env to override # for a non-China build. PIP_INDEX_URL: ${PIP_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple} restart: unless-stopped env_file: - .env environment: KB_SERVICE_HOST: 0.0.0.0 KB_SERVICE_PORT: 5010 # Shared bearer token between this container and the Node API # container. Both services MUST see the same value. Default is a # well-named placeholder so `docker compose up` works on a fresh # clone without any .env setup; production / staging MUST inject # a real value via env. The knowledge_service.py __main__ # fail-fast check otherwise refuses to start because # KB_SERVICE_HOST=0.0.0.0 is treated as "prod-shaped". KB_SERVICE_TOKEN: ${KB_SERVICE_TOKEN:-dev-only-local-token-NOT-FOR-PROD} HF_HOME: /root/.cache/huggingface SENTENCE_TRANSFORMERS_HOME: /root/.cache/sentence_transformers # HuggingFace endpoint for downloading the embedding model on # first boot. Defaults to the real huggingface.co — NOT empty: # huggingface_hub treats an empty string as a host-less endpoint # and breaks the fetch, whereas a real/unset value resolves # correctly. In mainland China huggingface.co is blocked, so set # HF_ENDPOINT=https://hf-mirror.com in .env (kb-service otherwise # fails warmup with "Network is unreachable" and never turns # healthy). env_file passes .env through; this line documents the # knob AND supplies the safe non-empty default. HF_ENDPOINT: ${HF_ENDPOINT:-https://huggingface.co} # KB read backend + DB connection. Defaults assume the # compose-managed pgvector postgres is the read side (matches # Phase 1.4 ingest output). To point kb-service at a host-side # native postgres instead -- the common dev setup where the # corpus was ingested locally via `npm run kb:ingest` against # native pg -- set KB_SERVICE_DATABASE_URL in .env, e.g.: # KB_SERVICE_DATABASE_URL=postgres://@host.docker.internal:5432/fshd_openrd # `host.docker.internal` works out-of-the-box on macOS/Windows # Docker Desktop; the `extra_hosts` entry below mirrors it on # Linux. To roll back to Chroma cloud, set KB_BACKEND=chroma_cloud # + KB_EMBED_MODEL=all-MiniLM-L6-v2 in .env. KB_BACKEND: ${KB_BACKEND:-pgvector} KB_EMBED_MODEL: ${KB_EMBED_MODEL:-BAAI/bge-m3} DATABASE_URL: ${KB_SERVICE_DATABASE_URL:-postgres://postgres:postgres@postgres:5432/fshd_openrd} ports: # Bound to 127.0.0.1 so direct host-side `curl localhost:5010` # debugging still works but a public-interface scan can't reach # the KB endpoint with the placeholder bearer token in # `dev-only-local-token-NOT-FOR-PROD`. The api ↔ kb path inside # compose goes through the compose bridge (`http://kb-service: # 5010`) and is unaffected. To open it across an internal # network set KB_SERVICE_PUBLISH_HOST=0.0.0.0 in `.env` and # inject a real KB_SERVICE_TOKEN first. - '${KB_SERVICE_PUBLISH_HOST:-127.0.0.1}:5010:5010' volumes: - kb-model-cache:/root/.cache extra_hosts: # Lets KB_SERVICE_DATABASE_URL=postgres://...@host.docker.internal:... # resolve the host on Linux too (Mac/Windows Docker Desktop add # this entry automatically; keeping it here is harmless on those # platforms and required everywhere else). - 'host.docker.internal:host-gateway' depends_on: postgres: condition: service_healthy healthcheck: test: [ 'CMD', 'python', '-c', "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5010/health/ready', timeout=3)", ] interval: 15s timeout: 5s # Budget: 15s × 30 = 7.5 min. Warm-cache boot (kb-model-cache # volume populated) takes ~30s for bge-m3 + initial embed. Cold # boot — first deploy or after `docker volume rm` — pulls the # ~2GB model weights from HuggingFace and can comfortably take # 3-5 min on a slow registry. The previous 150s budget would # mark the container unhealthy mid-pull, the api `depends_on: # kb-service: service_healthy` gate would never satisfy, and # the whole deploy would fail. Prefer pre-warming the # `kb-model-cache` volume out-of-band when feasible (see # runbook §3.1) so this budget never actually has to absorb a # full HF pull on the critical path. retries: 30 minio: image: minio/minio:latest profiles: ['minio'] restart: unless-stopped command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin12345678} ports: # Bound to 127.0.0.1 mirroring the postgres + kb-service # lockdown. The default MINIO_ROOT_PASSWORD is the well-known # Docker Hub demo value `minioadmin12345678`; a # `docker compose --profile minio up` on a public-IP'd staging # host would otherwise expose `medical-reports` bucket through # that default credential pair. To open it across an internal # network set MINIO_PUBLISH_HOST=0.0.0.0 in `.env` AND inject # real MINIO_ACCESS_KEY / MINIO_SECRET_KEY first. - '${MINIO_PUBLISH_HOST:-127.0.0.1}:9000:9000' - '${MINIO_PUBLISH_HOST:-127.0.0.1}:9001:9001' volumes: - minio-data:/data api: build: context: . dockerfile: apps/api/Dockerfile restart: unless-stopped env_file: - .env environment: # `apps/api/Dockerfile` pins ENV NODE_ENV=production for prod # image builds. Default to `development` here so a plain # `docker compose up` against a fresh clone does NOT trigger # `validateProductionEnv` — which would otherwise reject the # default `change-me-*` JWT / OTP secrets and the mock OTP # provider, even though the user just wants a local stack. Prod # / staging deploys explicitly override via secret injection. NODE_ENV: ${NODE_ENV:-development} DATABASE_URL: postgres://postgres:postgres@postgres:5432/fshd_openrd OCR_PROVIDER: ${OCR_PROVIDER:-embedded} OCR_PYTHON_BIN: python3 STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local} MINIO_ENDPOINT: ${MINIO_ENDPOINT:-minio:9000} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin12345678} MINIO_BUCKET_NAME: ${MINIO_BUCKET_NAME:-medical-reports} MINIO_USE_HTTPS: ${MINIO_USE_HTTPS:-false} KB_SERVICE_HOST: kb-service KB_SERVICE_PORT: 5010 KB_SERVICE_URL: http://kb-service:5010 # MUST be the same value the kb-service container sees. Use the # same `${KB_SERVICE_TOKEN:-...}` form so a missing .env entry # resolves to the identical placeholder on both sides — without # this line, only kb-service would see the placeholder and the # api → kb fetch would 401 with "unauthorized". KB_SERVICE_TOKEN: ${KB_SERVICE_TOKEN:-dev-only-local-token-NOT-FOR-PROD} ports: # Bound to 127.0.0.1: public traffic enters through the caddy # service (443), which reaches this container over the compose # network (api:4000). The host mapping is only for local # debugging (curl localhost:4000) — prod must not expose the # plaintext API directly. - '127.0.0.1:4000:4000' depends_on: postgres: condition: service_healthy kb-service: condition: service_healthy volumes: - api-uploads:/app/apps/api/uploads healthcheck: test: [ 'CMD', 'node', '-e', "fetch('http://127.0.0.1:4000/api/healthz/ready').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", ] interval: 15s timeout: 5s retries: 10 web: build: context: . dockerfile: apps/mobile/Dockerfile.web args: EXPO_PUBLIC_API_URL: ${WEB_EXPO_PUBLIC_API_URL:-/api} restart: unless-stopped depends_on: api: condition: service_healthy ports: # Bound to 127.0.0.1: the public entrypoint is the caddy service # on 80/443. caddy reaches this container over the compose # network (web:80), so this host mapping is only for local # debugging (curl localhost:8080). - '127.0.0.1:8080:80' # Reverse proxy + automatic HTTPS. Terminates TLS for the prod domain # using a Let's Encrypt cert Caddy obtains + auto-renews (HTTP-01 over # :80), then routes /api/* → api:4000 and /* → web:80 (see Caddyfile). # This is the ONLY service that publishes 0.0.0.0 ports in prod; # everything else is bound to 127.0.0.1 or the compose network. # # Gated behind the `prod` profile so a plain `docker compose up` for # local dev does NOT start Caddy (which would try — and fail — to get # a cert for the prod domain, and would grab :80/:443). Prod brings it # up with `docker compose --profile prod up -d --build`. caddy: image: caddy:2-alpine profiles: ['prod'] restart: unless-stopped environment: # Caddyfile reads {$DOMAIN}; override in prod `.env` if the domain # ever changes. Defaults to ai.fshdyouth.com. DOMAIN: ${DOMAIN:-ai.fshdyouth.com} ports: - '80:80' - '443:443' volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config depends_on: - api - web volumes: pg-data: api-uploads: kb-model-cache: minio-data: caddy-data: caddy-config: