# ========================================================================= # Maestro Platform — production stack (single host). # # docker compose -f docker-compose.prod.yml --env-file .env.prod up -d # # Caddy is the only service that publishes ports; everything else talks over # the compose network. See docs/DEPLOYMENT.md. # # Requires Compose v2.17+ for `service_completed_successfully`. # ========================================================================= name: maestro # json-file is Docker's default driver, but the size caps require naming it # explicitly. 10m x 5 files per service caps the whole stack around ~550MB — # noise on this host, whereas unbounded logs eventually fill any disk. # Changing these values requires recreating the containers (`up -d` does it). x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "5" services: postgres: image: postgres:16-alpine restart: unless-stopped logging: *default-logging environment: POSTGRES_USER: maestro POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env.prod} POSTGRES_DB: maestro volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U maestro -d maestro"] interval: 10s timeout: 5s retries: 5 deploy: resources: limits: memory: 512m mongo: image: mongo:7 restart: unless-stopped logging: *default-logging environment: MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:?set MONGO_USER in .env.prod} MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:?set MONGO_PASSWORD in .env.prod} volumes: - mongodata:/data/db healthcheck: test: - CMD - mongosh - --quiet - -u - ${MONGO_USER} - -p - ${MONGO_PASSWORD} - --authenticationDatabase - admin - --eval - db.adminCommand('ping').ok interval: 10s timeout: 5s retries: 5 deploy: resources: limits: memory: 1g qdrant: image: qdrant/qdrant:v1.18.2 restart: unless-stopped logging: *default-logging environment: QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY:?set QDRANT_API_KEY in .env.prod} volumes: - qdrantdata:/qdrant/storage # The image ships no curl/wget and its /bin/sh is dash, so CMD-SHELL cannot # probe anything — but bash *is* installed, and its /dev/tcp redirection # speaks enough HTTP to read Qdrant's own /readyz. That endpoint is exempt # from QDRANT__SERVICE__API_KEY, so the probe carries no credential. healthcheck: test: - CMD - bash - -c - "exec 3<>/dev/tcp/127.0.0.1/6333 && printf 'GET /readyz HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3" interval: 10s timeout: 5s retries: 5 deploy: resources: limits: memory: 1g # Rate-limit buckets, shared by every backend worker/instance. Deliberately # ephemeral: buckets expire within a minute, so persistence would only cost # disk. Losing them on restart forgives a few requests, nothing more. redis: image: redis:7-alpine restart: unless-stopped logging: *default-logging command: - redis-server - --requirepass - ${REDIS_PASSWORD:?set REDIS_PASSWORD in .env.prod} - --maxmemory - 128mb # Buckets carry their own TTL; eviction is just a backstop against a flood # of distinct client IPs filling memory faster than they expire. - --maxmemory-policy - allkeys-lru - --save - "" - --appendonly - "no" environment: # Read by the healthcheck below. `$$` keeps compose from interpolating it # here, so the value is resolved inside the container instead. REDIS_PASSWORD: ${REDIS_PASSWORD} healthcheck: test: - CMD-SHELL - redis-cli --no-auth-warning -a "$$REDIS_PASSWORD" ping | grep -q PONG interval: 10s timeout: 5s retries: 5 deploy: resources: limits: memory: 192m # Embeddings only. RAG and document upload call this whatever chat provider # the user picked; a chat model is NOT pulled here and OLLAMA_CHAT_ENABLED # stays false, so the free tier rejects task starts with a clear error. # Opting into the free chat tier (self-host) also requires raising the 2g # limit below — a 9B model does not load inside it. See docs/DEPLOYMENT.md. ollama: image: ollama/ollama:latest restart: unless-stopped logging: *default-logging volumes: - ollamadata:/root/.ollama healthcheck: test: ["CMD", "/bin/ollama", "list"] interval: 10s timeout: 5s retries: 5 start_period: 20s deploy: resources: limits: memory: 2g # Idempotent: exits immediately once the model is in the volume. ollama-pull: image: ollama/ollama:latest restart: "no" logging: *default-logging entrypoint: ["/bin/ollama"] command: ["pull", "${EMBEDDING_MODEL_NAME:-nomic-embed-text}"] environment: OLLAMA_HOST: http://ollama:11434 depends_on: ollama: condition: service_healthy # Streams the model straight to the shared volume; 512m is headroom, not a # measured requirement. See the note on `migrate` for why one-shots are capped. deploy: resources: limits: memory: 512m # Runs `alembic upgrade head` and exits. The API waits for it to succeed, so a # failed migration keeps the old backend serving rather than starting a new # one against an unmigrated schema. migrate: image: ghcr.io/yigtwxx/maestro-backend:${IMAGE_TAG:-latest} build: context: ./backend restart: "no" logging: *default-logging command: ["alembic", "upgrade", "head"] env_file: .env.prod depends_on: postgres: condition: service_healthy # One-shots get a cap for the same reason the long-running services do: they # share a single host, and an unbounded container that grows (a data # migration rewriting a large table) is reaped by the kernel's OOM killer, # which is free to pick Postgres or the backend instead of the offender. # 512m is several times what `alembic upgrade head` uses on this schema. deploy: resources: limits: memory: 512m backend: image: ghcr.io/yigtwxx/maestro-backend:${IMAGE_TAG:-latest} build: context: ./backend restart: unless-stopped logging: *default-logging env_file: .env.prod # Lets FREE_MODEL_ENDPOINT reach an Ollama installed on the host itself. # That Ollama must bind 0.0.0.0 (OLLAMA_HOST=0.0.0.0), not loopback. extra_hosts: - "host.docker.internal:host-gateway" depends_on: postgres: condition: service_healthy mongo: condition: service_healthy qdrant: condition: service_healthy redis: condition: service_healthy migrate: condition: service_completed_successfully ollama-pull: condition: service_completed_successfully deploy: resources: limits: memory: 2g frontend: image: ghcr.io/yigtwxx/maestro-frontend:${IMAGE_TAG:-latest} build: context: ./frontend restart: unless-stopped logging: *default-logging environment: # /pricing and /templates are force-dynamic server components: they fetch # from inside this container, where a same-origin relative URL has no base. INTERNAL_API_ORIGIN: http://backend:8000 # The public origin used for canonical URLs, sitemap.xml and OG tags. Read # at request time, so the image stays domain-agnostic (see DEPLOYMENT.md). # Not derived from DOMAIN: DOMAIN may carry a scheme (e.g. http://localhost), # which would produce https://http://localhost. SITE_URL: ${SITE_URL:?set SITE_URL in .env.prod} # Umami website id; empty/unset = analytics fully disabled (no script is # served and the storage notice stays informational). Read at request # time like SITE_URL. `:-` on purpose: it must not break deployments # that never enable the analytics profile. UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-} # Frontend Sentry project DSN (a *separate* Sentry project from the # backend's SENTRY_DSN, which reaches the backend via env_file — two # projects, one env file, hence the distinct variable name). Empty/unset # = error tracking fully disabled: no SDK chunk is served, zero egress. # Read at request/startup time like SITE_URL, never baked into the image. SENTRY_DSN: ${FRONTEND_SENTRY_DSN:-} SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production} depends_on: - backend deploy: resources: limits: memory: 512m # --- Optional self-hosted analytics (COMPOSE_PROFILES=analytics) --- # One-shot: creates the umami role and database inside the existing postgres. # Needed because /docker-entrypoint-initdb.d scripts never re-run on an # already-initialized volume, and existing deployments have one. Idempotent: # the role is created once (its password re-synced on every run, so a # changed UMAMI_DB_PASSWORD propagates), the database created only if absent. umami-db-init: image: postgres:16-alpine restart: "no" logging: *default-logging profiles: ["analytics"] environment: PGHOST: postgres PGUSER: maestro PGDATABASE: maestro PGPASSWORD: ${POSTGRES_PASSWORD:-} UMAMI_DB_PASSWORD: ${UMAMI_DB_PASSWORD:-} entrypoint: ["/bin/sh", "-c"] # $$ keeps compose from interpolating; the value resolves in-container. command: - | set -eu psql -v ON_ERROR_STOP=1 -v pw="$$UMAMI_DB_PASSWORD" <<'SQL' SELECT format('CREATE ROLE umami LOGIN PASSWORD %L', :'pw') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'umami')\gexec SELECT format('ALTER ROLE umami PASSWORD %L', :'pw')\gexec SELECT 'CREATE DATABASE umami OWNER umami' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'umami')\gexec SQL depends_on: postgres: condition: service_healthy # A single psql session. See the note on `migrate`. deploy: resources: limits: memory: 128m # Runs its own Prisma migrations on startup — no separate migrate service. # Caddy exposes only /a/script.js and /a/api/send; the dashboard is reachable # solely through the loopback port below (SSH tunnel), never the internet. umami: image: ghcr.io/umami-software/umami:postgresql-v3.2.0 restart: unless-stopped logging: *default-logging profiles: ["analytics"] environment: DATABASE_URL: postgresql://umami:${UMAMI_DB_PASSWORD:-}@postgres:5432/umami APP_SECRET: ${UMAMI_APP_SECRET:-} DISABLE_TELEMETRY: "1" ports: - "127.0.0.1:3001:3000" depends_on: postgres: condition: service_healthy umami-db-init: condition: service_completed_successfully healthcheck: test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/heartbeat"] interval: 15s timeout: 5s retries: 5 # First boot runs the Prisma migrations; give it room. start_period: 60s deploy: resources: limits: memory: 512m # External uptime probe, off unless COMPOSE_PROFILES includes "uptime". # # The backend runs its own readiness watchdog, but that watchdog cannot report # a backend which is itself wedged, OOM-killed or deadlocked — the one failure # an operator most needs to hear about. This container closes exactly that # blind spot by polling from outside the process, and it POSTs to the webhook # itself rather than asking the backend to deliver anything. # # It probes `backend:8000` directly rather than through Caddy, so a failure # here means the app is down, not the proxy. It is NOT a substitute for a free # external monitor (UptimeRobot, Better Stack) on https:///health: # this shares the host it watches, so it cannot report the host being gone. # # curl-on-Alpine rather than a dashboard: this sits at 3-6 MB RSS, where # Uptime Kuma (Node + SQLite + socket.io) needs 150-250 MB, a volume and an # interactive first run. Spending that on two URLs is the wrong trade for the # single small box this file targets — install Kuma separately if you want a # dashboard and have the memory. uptime: image: curlimages/curl:8.11.1 restart: unless-stopped logging: *default-logging profiles: ["uptime"] environment: ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:?set ALERT_WEBHOOK_URL to use the uptime profile} UPTIME_INTERVAL_SECONDS: ${UPTIME_INTERVAL_SECONDS:-60} UPTIME_FAILURES_BEFORE_ALERT: ${UPTIME_FAILURES_BEFORE_ALERT:-2} depends_on: - backend # Alerts on a state transition, never on a tick — the same discipline as the # in-process watchdog, so a sustained outage pages once. `curl` runs without # -L: the repo follows no redirects anywhere but repo_intel. entrypoint: ["/bin/sh", "-c"] command: - | set -eu fails=0 state=up notify() { body="$$(printf '%s' "$$1" | sed 's/"/\\"/g')" curl -sS -m 10 -X POST -H 'Content-Type: application/json' \ -d "{\"text\":\"$$body\",\"content\":\"$$body\"}" \ "$$ALERT_WEBHOOK_URL" >/dev/null 2>&1 || true } echo "uptime probe watching backend:8000 every $${UPTIME_INTERVAL_SECONDS}s" while true; do sleep "$$UPTIME_INTERVAL_SECONDS" if curl -sf -m 10 -o /dev/null http://backend:8000/health \ && curl -s -m 10 -o /dev/null -w '%{http_code}' \ http://backend:8000/health/ready | grep -q '^200$$'; then if [ "$$state" = down ]; then notify "Maestro backend is reachable again (external probe)." state=up fi fails=0 else fails=$$((fails + 1)) if [ "$$fails" -ge "$$UPTIME_FAILURES_BEFORE_ALERT" ] \ && [ "$$state" = up ]; then notify "Maestro backend is NOT responding from outside the process. \ The in-process watchdog cannot report this. Checked http://backend:8000/health \ and /health/ready, $$fails consecutive failures." state=down fi fi done deploy: resources: limits: memory: 64m caddy: image: caddy:2-alpine restart: unless-stopped logging: *default-logging ports: - "80:80" - "443:443" environment: DOMAIN: ${DOMAIN:?set DOMAIN in .env.prod} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddydata:/data - caddyconfig:/config depends_on: - backend - frontend # The last unbounded service: a proxy that buffers uploads for every request # in flight is exactly what a flood grows, and it must not be able to starve # the app it fronts. 256m is an order of magnitude above its steady state. deploy: resources: limits: memory: 256m volumes: pgdata: mongodata: qdrantdata: ollamadata: caddydata: caddyconfig: