Ctrl K
ComunidadGuías…

docker-compose.prod.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# =========================================================================
# 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://<domain>/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: