Everything needed to run the app on an Intel NAS that is also doing other jobs: one published port, no host networking, memory/CPU caps and log rotation on every container, and all state in bind mounts under DATA_ROOT so mail survives image updates, project deletion in the NAS UI, and full reinstalls — and so Hyper Backup/HBS3 can actually see it. - docker/Dockerfile.backend: multi-stage, non-root (uid 10001), tini for the <10s graceful lease release. Installs the four plugins as entry-point distributions. One image, several roles via docker/entrypoint.sh (api | worker-sync | worker-jobs | migrate | reindex | plugins). - docker/Dockerfile.frontend + nginx.conf: SPA + /api reverse proxy with the WebSocket upgrade for /api/ws, and X-Forwarded-Proto preserved so the API still emits __Host-/Secure cookies when TLS is terminated by the NAS reverse proxy (identity/cookies.py keys off request scheme). - docker/docker-compose.prod.yml: project name webmail-prod, NOT webmail — docker-compose.yml (dev services) owns that name, and two compose files sharing one project name fight over each other's containers. Meilisearch is an opt-in profile; SEARCH_BACKEND=pg is the default so a small NAS pays nothing for search. - migrate runs as a one-shot (core Alembic head + every plugin branch) that everything else waits on, so a bad migration can never leave new code running against an old schema. - docker/backup.sh: nightly pg_dump verified with `pg_restore --list` before it counts as a backup, with retention and a baseline on first start. - deploy/webmail.sh: init/doctor/up/down/status/logs/update/rollback/ backup/restore/reindex/psql/shell. `update` is backup -> images -> migrate -> roll workers, api, web -> health-gate, with automatic rollback to the previous version if the gate fails. `doctor` refuses to be quiet when DATA_ROOT is on NFS/SMB (silent Postgres corruption). - deploy/build-push.sh: build images off-NAS (the frontend build wants ~2GB RAM) and push to Gitea, or --save a tar to carry over. - scripts/reindex_search.py: rebuild the search index from Postgres alone. Every SearchDoc field is already stored, so the index is derived data that never needs backing up — this closes the only real objection to running Meilisearch on a box whose volumes may be wiped. - pyproject: package-data for accounts/presets.yaml and the shared plugin Alembic env. Without it a non-editable install (i.e. any image) silently ships a package that fails at runtime. - GET /api/version, used by the deploy health gate. Verified end to end on a real stack: fresh install, all services healthy, core + 4 plugin migration branches applied, login/SPA/plugin bundles through nginx, WS upgrade returning 101, __Host- cookies under a simulated TLS proxy, backup -> restore round-trip preserving user and plugin schema, failed-update auto-rollback (both a bad migration and a boot-crashing API), and Meilisearch profile serving a live query. ruff + mypy --strict clean, 363/363 backend tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
63 lines
2.4 KiB
Bash
Executable File
63 lines
2.4 KiB
Bash
Executable File
#!/bin/sh
|
|
# One image, several roles. Called as: entrypoint <command> [args...]
|
|
#
|
|
# api uvicorn, behind the `web` nginx container
|
|
# worker-sync lease-owned account sync supervisor (SPEC §6.3)
|
|
# worker-jobs arq consumer (interactive + bulk queues)
|
|
# worker-all both in one process (small installs; halves RAM use)
|
|
# migrate core Alembic upgrade + per-plugin install; exits 0 when done
|
|
# reindex rebuild the search index from Postgres
|
|
# plugins `python -m app.plugins.cli ...` passthrough
|
|
# shell /bin/sh (debugging)
|
|
#
|
|
# Any other argument is exec'd verbatim, so `docker compose run api python -c ...`
|
|
# still works.
|
|
set -eu
|
|
|
|
: "${API_HOST:=0.0.0.0}"
|
|
: "${API_PORT:=8000}"
|
|
: "${API_WORKERS:=1}"
|
|
# Plugins whose migrations are applied on every `migrate` run. Space-separated so
|
|
# an operator can add a third-party plugin without editing the image.
|
|
: "${ENABLED_PLUGINS:=calendar contacts labels filters}"
|
|
|
|
cmd="${1:-api}"
|
|
[ "$#" -gt 0 ] && shift || true
|
|
|
|
run_migrations() {
|
|
echo "[entrypoint] core schema: alembic upgrade head"
|
|
alembic upgrade head
|
|
for plugin in $ENABLED_PLUGINS; do
|
|
echo "[entrypoint] plugin schema: $plugin"
|
|
python -m app.plugins.cli install "$plugin"
|
|
done
|
|
echo "[entrypoint] migrations complete"
|
|
}
|
|
|
|
case "$cmd" in
|
|
api)
|
|
# --proxy-headers + --forwarded-allow-ips are what let the app see the
|
|
# real scheme through nginx/DSM. Without them every request looks like
|
|
# plain http and the session cookie loses its __Host- prefix and Secure
|
|
# flag (app/identity/cookies.py:22). The allow-list is the internal
|
|
# docker network only; this port is never published directly.
|
|
exec uvicorn app.main:create_app \
|
|
--factory \
|
|
--host "$API_HOST" \
|
|
--port "$API_PORT" \
|
|
--workers "$API_WORKERS" \
|
|
--proxy-headers \
|
|
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
|
|
--no-server-header \
|
|
"$@"
|
|
;;
|
|
worker-sync) WORKER_ROLE=sync exec python -m workers.run "$@" ;;
|
|
worker-jobs) WORKER_ROLE=jobs exec python -m workers.run "$@" ;;
|
|
worker-all) WORKER_ROLE=all exec python -m workers.run "$@" ;;
|
|
migrate) run_migrations ;;
|
|
reindex) exec python scripts/reindex_search.py "$@" ;;
|
|
plugins) exec python -m app.plugins.cli "$@" ;;
|
|
shell) exec /bin/sh "$@" ;;
|
|
*) exec "$cmd" "$@" ;;
|
|
esac
|