docker-compose.prod.yml
Rama main · réplica de lectura del repositorio original.
# =========================================================================
# 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: