feat(deploy): production docker compose stack + Gitea CI image build
Adds a complete deployment surface for production:
Dockerfile multi-stage 3.12-slim build, collectstatic
baked into the image, runs as non-root mnemosyne
uid/gid 1000.
docker/entrypoint.sh dispatches `web | mcp | worker | beat | migrate
| setup | shell` from a single image, so every
service in compose runs the same artifact.
docker-compose.yaml five services: static-init (one-shot copies
statics into the shared volume on every up),
web (gunicorn), mcp (uvicorn), worker (celery),
nginx. External services (Postgres, Neo4j,
RabbitMQ, S3, Memcached, embedder, reranker)
reached over the 10.10.0.0/24 internal network
and configured via mnemosyne/.env.
nginx/mnemosyne.conf reverse proxy: /library/* and /admin/* → web,
/mcp/* → mcp, /static/* → volume, /metrics
internal-network-only (127/8 + RFC1918), /healthz
proxies to /mcp/health for liveness probes.
.gitea/workflows/ CVE scan + image build, image pushed to
git.helu.ca/r/mnemosyne. Trivy scans pyproject
extras (dev/test/lint/docs) and the built image.
pyproject.toml adds [test], [lint], [docs] extras so the CI
pip-compile step has something to resolve.
README documents the bring-up flow (`docker compose run --rm web migrate`,
then `setup`, then `up -d`), day-to-day commands, and the env-var values
that need adjusting for production (DEBUG=False, KVDB_LOCATION pointing
at the external memcached, AWS keys filled in, etc.).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
111
docker-compose.yaml
Normal file
111
docker-compose.yaml
Normal file
@@ -0,0 +1,111 @@
|
||||
# =============================================================================
|
||||
# Mnemosyne — production deployment
|
||||
# =============================================================================
|
||||
# Four services, all from the same image:
|
||||
# web — Django REST API + admin (gunicorn, port 8000)
|
||||
# mcp — FastMCP server (uvicorn, port 22091)
|
||||
# worker — Celery worker (embedding/ingest/batch queues)
|
||||
# nginx — reverse proxy, public port 23090
|
||||
#
|
||||
# External services (NOT spun up here): Postgres on Portia, Neo4j on Ariel,
|
||||
# RabbitMQ on Oberon, S3/MinIO on Nyx, Memcached on its own host, embedder
|
||||
# and reranker on Nyx, smtp4dev on Oberon. All reached over the internal
|
||||
# 10.10.0.0/24 network.
|
||||
#
|
||||
# Run:
|
||||
# docker compose up -d
|
||||
# docker compose run --rm web migrate # one-shot DB migrate
|
||||
# docker compose run --rm web setup # Neo4j indexes + library types
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ── Static-file seeder: copies /app/staticfiles into the shared volume on
|
||||
# every `up`. Runs once and exits. Without this, the named volume is only
|
||||
# seeded the first time it's empty, so static updates between deploys
|
||||
# would not propagate to nginx.
|
||||
static-init:
|
||||
image: git.helu.ca/r/mnemosyne:latest
|
||||
command: ["sh", "-c", "cp -a /app/staticfiles/. /shared-static/"]
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- mnemosyne-static:/shared-static
|
||||
restart: "no"
|
||||
|
||||
# ── Web app: Django REST API + admin ───────────────────────────────────────
|
||||
web:
|
||||
image: git.helu.ca/r/mnemosyne:latest
|
||||
command: ["web"]
|
||||
env_file: mnemosyne/.env
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
static-init:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- mnemosyne-media:/app/media
|
||||
expose:
|
||||
- "8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/admin/login/').read()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── MCP server: FastMCP Streamable HTTP at /mcp/ ───────────────────────────
|
||||
mcp:
|
||||
image: git.helu.ca/r/mnemosyne:latest
|
||||
command: ["mcp"]
|
||||
env_file: mnemosyne/.env
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mnemosyne-media:/app/media
|
||||
expose:
|
||||
- "22091"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:22091/mcp/health').read()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# ── Celery worker: embedding + ingest + batch queues ───────────────────────
|
||||
worker:
|
||||
image: git.helu.ca/r/mnemosyne:latest
|
||||
command: ["worker"]
|
||||
env_file: mnemosyne/.env
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mnemosyne-media:/app/media
|
||||
healthcheck:
|
||||
test: ["CMD", "celery", "-A", "mnemosyne", "inspect", "ping", "-d", "celery@$$HOSTNAME"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# ── nginx: reverse proxy, public port 23090 ────────────────────────────────
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- web
|
||||
- mcp
|
||||
ports:
|
||||
- "23090:80"
|
||||
volumes:
|
||||
- ./nginx/mnemosyne.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- mnemosyne-static:/var/www/static:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
# Static files baked into the image at /app/staticfiles. The web service
|
||||
# mounts this volume, populating it on first start; nginx reads from it.
|
||||
mnemosyne-static:
|
||||
# Local FileSystemStorage fallback. Production uses USE_LOCAL_STORAGE=False
|
||||
# so this is mostly empty — kept for parity with dev and for any path
|
||||
# that writes to MEDIA_ROOT directly.
|
||||
mnemosyne-media:
|
||||
Reference in New Issue
Block a user