services: postgres: image: postgres:16-alpine restart: unless-stopped environment: # No defaults on purpose: POSTGRES_USER/PASSWORD/DB must be set in your # local, git-ignored .env (see .env.example). Compose fails loudly if # they're missing instead of falling back to a guessable credential. POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env} ports: - "${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"] interval: 5s timeout: 5s retries: 5 # Single service serving both the API and the built frontend (see # apps/api/Dockerfile) — no separate nginx/web container, no cross-origin # CORS_ORIGIN to keep in sync between two ports. app: build: context: . dockerfile: apps/api/Dockerfile restart: unless-stopped environment: NODE_ENV: production PORT: 3000 # Uses the "postgres" service name, not localhost/POSTGRES_PORT — # container-to-container traffic stays on the compose network and # always targets Postgres's internal port (5432). DATABASE_URL: "postgresql://${POSTGRES_USER:?set POSTGRES_USER in .env}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/${POSTGRES_DB:?set POSTGRES_DB in .env}?schema=public" JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env} # Unset by default (falls back to NODE_ENV === "production", i.e. # Secure cookie required) — set COOKIE_SECURE=false in .env only if # this deployment is reachable over plain HTTP (no TLS in front of # it yet), otherwise the session cookie never comes back and every # authenticated request 401s despite login succeeding. See its doc # comment in apps/api/src/config/env.ts. COOKIE_SECURE: ${COOKIE_SECURE:-} # Shared with the `tech-step-llm-worker` service below — see # requireInternalWorker's doc comment # (apps/api/src/middlewares/require-internal-worker.ts). Unset by # default: `/internal/tech-steps/*` fails closed rather than open # for a deployment that doesn't run the worker at all. INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-} # Compose network service name, not localhost — same reasoning as # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # tech-step-intent-service is a core dependency (see its own entry # below), not an optional background job. INTENT_SERVICE_BASE_URL: "http://tech-step-intent-service:8000" INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env} ports: - "${APP_PORT:-3000}:3000" depends_on: postgres: condition: service_healthy tech-step-intent-service: condition: service_healthy # spaCy-based NER + intent classification microservice # (services/tech-step-intent-service) — `app` delegates all tech-step # detection to it over HTTP (see `IntentServiceClient`, # apps/api/src/lib/recipe-matching/intent-service-client.ts). Unlike # `tech-step-llm-worker` below, **not optional**: without it, `app` can no # longer detect any cooking technique in a recipe step at all. No exposed # port — reachable only from `app` on the compose network, nothing ever # calls into it from outside. tech-step-intent-service: build: context: . dockerfile: services/tech-step-intent-service/Dockerfile restart: unless-stopped environment: INTENT_SERVICE_SECRET: ${INTENT_SERVICE_SECRET:?set INTENT_SERVICE_SECRET in .env} healthcheck: # No curl/wget in the python:3.12-slim base image — a one-line Python # request is the healthcheck for a service that's already guaranteed # to have Python (see this service's Dockerfile). test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2)", ] interval: 15s timeout: 3s retries: 5 # This service trains itself from scratch on every start (no model # ever persisted to disk, see its own README) — `/health` only # returns 200 once that's done, not just once the base spaCy models # are loaded. Measured at ~690s per locale (~1340s for fr+en # combined) against the current ~74-technique corpus, each now # rebalanced toward 20 `utterances` in addition to its `synonyms` # (`intent_service/locale_pipeline.py`'s `_TRAINING_ITERATIONS`, # raised from `10` after a smaller value measurably failed this # repo's own F1 quality gate — see that constant's own comment) — # `start_period` generous enough that failing checks during that # whole window never count against `retries` (which would otherwise # flip this container to "unhealthy" mid-training, blocking `app`'s # own `depends_on: condition: service_healthy` indefinitely). start_period: 1800s # Deliberately its own image, not built into `app`'s (see # services/tech-step-llm-worker/Dockerfile's own doc comment) — a # long-lived process with no exposed port (nothing ever calls *into* it, # it only ever calls out to `app`). Optional: an `INTERNAL_WORKER_SECRET`- # less deployment can omit this service entirely and `app` still runs # fine, just without the offline audit/feedback-loop jobs. tech-step-llm-worker: build: context: . dockerfile: services/tech-step-llm-worker/Dockerfile restart: unless-stopped depends_on: - app environment: API_BASE_URL: "http://app:3000" INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:?set INTERNAL_WORKER_SECRET in .env to run this service} TECH_STEP_WORKER_CRON: ${TECH_STEP_WORKER_CRON:-0 3 * * 0} volumes: # GGUF weights persist across restarts — see this service's own # Dockerfile doc comment on its VOLUME declaration. - tech_step_llm_worker_models:/worker/models volumes: postgres_data: tech_step_llm_worker_models: