Deploy: production Docker stack for a single host (Synology/QNAP NAS)
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
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# ============================================================================
|
||||
# Webmail production configuration.
|
||||
#
|
||||
# Copy to docker/.env.prod, fill in the three REQUIRED values, keep it safe:
|
||||
# cp docker/.env.prod.example docker/.env.prod && chmod 600 docker/.env.prod
|
||||
#
|
||||
# THIS FILE IS A BACKUP ARTEFACT. MASTER_KEY is not stored anywhere else: lose
|
||||
# it and every saved mail-account password becomes permanently undecryptable,
|
||||
# even from a perfect database backup. Keep a copy in a password manager.
|
||||
# ============================================================================
|
||||
|
||||
# --- REQUIRED ---------------------------------------------------------------
|
||||
|
||||
# Where all persistent data lives on the host. Use a real shared folder your NAS
|
||||
# backup tool can see — not a Docker named volume.
|
||||
# Synology: /volume1/docker/webmail/data
|
||||
# QNAP: /share/Container/webmail/data
|
||||
DATA_ROOT=/volume1/docker/webmail/data
|
||||
|
||||
# 32 random bytes, base64. Generate ONCE, then never change it:
|
||||
# openssl rand -base64 32
|
||||
MASTER_KEY=
|
||||
|
||||
# Database password. Generate once:
|
||||
# openssl rand -base64 24 | tr -d '/+=' | cut -c1-24
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
# --- Identity ---------------------------------------------------------------
|
||||
# The domain your user handles are minted under (user@APP_DOMAIN) and the issuer
|
||||
# shown in 2FA authenticator apps. It does NOT have to be publicly resolvable.
|
||||
APP_DOMAIN=mail.home.lan
|
||||
|
||||
# --- Network ----------------------------------------------------------------
|
||||
# The single published port. Avoid 5000/5001 (Synology DSM) and 8080 (QNAP QTS).
|
||||
WEB_PORT=8088
|
||||
# Bind to one NAS interface instead of every network, e.g. WEB_BIND=192.168.1.185
|
||||
WEB_BIND=0.0.0.0
|
||||
|
||||
TZ=Europe/Warsaw
|
||||
|
||||
# --- Search -----------------------------------------------------------------
|
||||
# pg = Postgres full-text. No extra container, no extra RAM. Default.
|
||||
# meili = Meilisearch: typo tolerance, better ranking, faster on large mailboxes.
|
||||
# Costs a container and ~0.7-1 GB RAM. Start it with:
|
||||
# ./deploy/webmail.sh up --meili
|
||||
# and after switching an existing install, rebuild the index:
|
||||
# ./deploy/webmail.sh reindex
|
||||
SEARCH_BACKEND=pg
|
||||
# Required if SEARCH_BACKEND=meili — generate with: openssl rand -base64 32
|
||||
MEILI_MASTER_KEY=
|
||||
|
||||
# --- Images -----------------------------------------------------------------
|
||||
# `local` = built on this host from the repo. To deploy prebuilt images from
|
||||
# your Gitea registry instead, set both of these (see deploy/build-push.sh):
|
||||
# REGISTRY_PREFIX=192.168.1.185:8418/claude/webmail
|
||||
# IMAGE_TAG=v1.0.0
|
||||
REGISTRY_PREFIX=webmail
|
||||
IMAGE_TAG=local
|
||||
|
||||
# --- Application ------------------------------------------------------------
|
||||
LOG_LEVEL=INFO
|
||||
UNDO_SECONDS=10
|
||||
SESSION_TTL_DAYS=30
|
||||
MAX_ATTACHMENT_MB=25
|
||||
# Auto-purge Trash/Spam older than N days (0 disables).
|
||||
TRASH_RETENTION_DAYS=30
|
||||
# Server-side sender avatars (Gravatar + favicon), fetched by the server so
|
||||
# senders never see your IP. Set false to make zero outbound web requests.
|
||||
SENDER_AVATARS_ENABLED=true
|
||||
|
||||
GOOGLE_OAUTH_ENABLED=false
|
||||
OAUTH_GOOGLE_CLIENT_ID=
|
||||
OAUTH_GOOGLE_CLIENT_SECRET=
|
||||
OAUTH_MS_CLIENT_ID=
|
||||
OAUTH_MS_CLIENT_SECRET=
|
||||
|
||||
# Plugins whose schema is migrated on every start. Remove one to stop shipping
|
||||
# its tables; `hello` is the SDK demo and is intentionally not listed.
|
||||
ENABLED_PLUGINS=calendar contacts labels filters
|
||||
|
||||
# --- Backups ----------------------------------------------------------------
|
||||
BACKUP_AT=03:30
|
||||
BACKUP_KEEP_DAYS=14
|
||||
|
||||
# --- Sizing (defaults suit a personal install on a shared NAS) --------------
|
||||
# Peak worker RAM is roughly PARSE_CONCURRENCY x STREAM_PARSE_MB plus overhead.
|
||||
INTERACTIVE_CONCURRENCY=8
|
||||
BULK_CONCURRENCY=1
|
||||
PARSE_CONCURRENCY=2
|
||||
STREAM_PARSE_MB=8
|
||||
PER_HOST_CONN_CAP=10
|
||||
HOT_BUDGET_PER_WORKER=50
|
||||
|
||||
# Container caps. Total without Meilisearch: ~2.2 GB of *limits*; real steady
|
||||
# usage for one or two mail accounts is around 700 MB-1 GB.
|
||||
PG_MEM_LIMIT=768m
|
||||
PG_SHARED_BUFFERS=256MB
|
||||
PG_MAX_CONNECTIONS=100
|
||||
PG_WORK_MEM=8MB
|
||||
PG_MAINTENANCE_WORK_MEM=128MB
|
||||
PG_EFFECTIVE_CACHE_SIZE=768MB
|
||||
REDIS_MEM_LIMIT=160m
|
||||
REDIS_MAXMEMORY=96mb
|
||||
API_MEM_LIMIT=512m
|
||||
API_CPUS=1.0
|
||||
WORKER_SYNC_MEM_LIMIT=512m
|
||||
WORKER_JOBS_MEM_LIMIT=640m
|
||||
WORKER_CPUS=1.5
|
||||
WEB_MEM_LIMIT=96m
|
||||
BACKUP_MEM_LIMIT=256m
|
||||
MEILI_MEM_LIMIT=1g
|
||||
@@ -0,0 +1,79 @@
|
||||
# Production backend image: API, both worker roles, and the migration/admin CLI.
|
||||
#
|
||||
# One image, several commands (see docker/entrypoint.sh) — matches SPEC §6.3's
|
||||
# WORKER_ROLE split. Build context is the REPO ROOT, because the image needs
|
||||
# backend/ *and* plugins/ (the five plugins are separate distributions that the
|
||||
# loader discovers through the `webmail.plugins` entry-point group, SPEC §11).
|
||||
#
|
||||
# docker build -f docker/Dockerfile.backend -t webmail/backend .
|
||||
|
||||
# ---------- builder: resolve wheels into a self-contained venv ----------------
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# Only needed if a pinned dep has no manylinux wheel for this platform; the
|
||||
# whole stage is discarded, so it costs nothing in the runtime image.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Backend first: its dependency set is the slowest layer and changes least often.
|
||||
COPY backend/pyproject.toml /src/backend/pyproject.toml
|
||||
COPY backend/app /src/backend/app
|
||||
COPY backend/workers /src/backend/workers
|
||||
RUN pip install /src/backend
|
||||
|
||||
# Plugins are ordinary distributions installed on top (each owns its own deps).
|
||||
COPY plugins /src/plugins
|
||||
RUN pip install \
|
||||
/src/plugins/webmail_plugin_calendar \
|
||||
/src/plugins/webmail_plugin_contacts \
|
||||
/src/plugins/webmail_plugin_labels \
|
||||
/src/plugins/webmail_plugin_filters
|
||||
|
||||
# ---------- runtime -----------------------------------------------------------
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="webmail-backend" \
|
||||
org.opencontainers.image.version="${APP_VERSION}" \
|
||||
org.opencontainers.image.revision="${GIT_SHA}"
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PATH="/opt/venv/bin:$PATH" \
|
||||
APP_VERSION="${APP_VERSION}" \
|
||||
GIT_SHA="${GIT_SHA}"
|
||||
|
||||
# tini reaps zombies and forwards SIGTERM, which the workers need for the <10 s
|
||||
# graceful lease release in workers/run.py.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tini \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --create-home --home-dir /home/webmail webmail
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
WORKDIR /app
|
||||
# Alembic's core tree lives outside the installed package, so it is copied in.
|
||||
COPY backend/alembic.ini ./alembic.ini
|
||||
COPY backend/migrations ./migrations
|
||||
COPY backend/scripts ./scripts
|
||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
|
||||
RUN chmod +x /usr/local/bin/entrypoint
|
||||
|
||||
# Blob store mount point (BLOB_ROOT). Ownership is set by the compose `init`
|
||||
# service, which runs before anything else touches the bind mount.
|
||||
RUN mkdir -p /data/blobs && chown webmail:webmail /data/blobs
|
||||
VOLUME ["/data/blobs"]
|
||||
|
||||
USER webmail
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint"]
|
||||
CMD ["api"]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Production web image: the built SPA served by nginx, which also reverse-proxies
|
||||
# /api (including the /api/ws WebSocket) to the API container. Build context is
|
||||
# the REPO ROOT so the build stage can see frontend/.
|
||||
#
|
||||
# docker build -f docker/Dockerfile.frontend -t webmail/web .
|
||||
|
||||
# ---------- builder -----------------------------------------------------------
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /src
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
# `npm ci` when a lockfile is present (reproducible), `npm install` otherwise.
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY frontend/ ./
|
||||
# Type-check + bundle. `npm run build` runs `tsc --noEmit && vite build`, so a
|
||||
# type error fails the image build rather than shipping a broken bundle.
|
||||
RUN npm run build
|
||||
|
||||
# ---------- runtime -----------------------------------------------------------
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
ARG APP_VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="webmail-web" \
|
||||
org.opencontainers.image.version="${APP_VERSION}" \
|
||||
org.opencontainers.image.revision="${GIT_SHA}"
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /src/dist /usr/share/nginx/html
|
||||
|
||||
# Surface the running version to `deploy/webmail.sh status` without needing the API.
|
||||
RUN printf '%s\n' "${APP_VERSION}" > /usr/share/nginx/html/version.txt
|
||||
|
||||
EXPOSE 80
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# Nightly logical backup of the webmail database.
|
||||
#
|
||||
# Runs as a long-lived sidecar: sleeps until BACKUP_AT, dumps, verifies, prunes,
|
||||
# repeats. `docker compose run --rm backup once` takes a single backup now (this
|
||||
# is what `deploy/webmail.sh backup` does, e.g. immediately before an update).
|
||||
#
|
||||
# Why logical dumps and not just a NAS snapshot: a btrfs/Hyper Backup snapshot of
|
||||
# a *running* Postgres data directory is crash-consistent, which usually replays
|
||||
# fine but is not guaranteed and cannot be restored into a different Postgres
|
||||
# version. A pg_dump is version-portable and verifiable. Keep both.
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-/backups}"
|
||||
BACKUP_AT="${BACKUP_AT:-03:30}"
|
||||
BACKUP_KEEP_DAYS="${BACKUP_KEEP_DAYS:-14}"
|
||||
DB="${PGDATABASE:-webmail}"
|
||||
|
||||
log() { echo "[backup $(date -Is)] $*"; }
|
||||
|
||||
take_backup() {
|
||||
local stamp out globals
|
||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||
out="${BACKUP_DIR}/${DB}-${stamp}.dump"
|
||||
globals="${BACKUP_DIR}/globals-${stamp}.sql"
|
||||
|
||||
log "dumping ${DB} -> $(basename "$out")"
|
||||
# -Fc: compressed custom format, restorable selectively with pg_restore.
|
||||
if ! pg_dump --format=custom --compress=6 --file="${out}.partial" "$DB"; then
|
||||
log "ERROR: pg_dump failed; leaving no partial file"
|
||||
rm -f "${out}.partial"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verify the archive is readable before it counts as a backup.
|
||||
if ! pg_restore --list "${out}.partial" >/dev/null 2>&1; then
|
||||
log "ERROR: dump failed verification (pg_restore --list); discarding"
|
||||
rm -f "${out}.partial"
|
||||
return 1
|
||||
fi
|
||||
|
||||
mv "${out}.partial" "$out"
|
||||
chmod 600 "$out"
|
||||
|
||||
# Roles/passwords live outside the database; tiny, and makes a restore onto a
|
||||
# bare Postgres one step shorter.
|
||||
pg_dumpall --globals-only --file="$globals" 2>/dev/null || true
|
||||
[ -f "$globals" ] && chmod 600 "$globals"
|
||||
|
||||
ln -sfn "$(basename "$out")" "${BACKUP_DIR}/latest.dump"
|
||||
log "ok: $(du -h "$out" | cut -f1) $(basename "$out")"
|
||||
|
||||
log "pruning dumps older than ${BACKUP_KEEP_DAYS} days"
|
||||
find "$BACKUP_DIR" -maxdepth 1 -name "${DB}-*.dump" -mtime "+${BACKUP_KEEP_DAYS}" -print -delete || true
|
||||
find "$BACKUP_DIR" -maxdepth 1 -name "globals-*.sql" -mtime "+${BACKUP_KEEP_DAYS}" -print -delete || true
|
||||
}
|
||||
|
||||
seconds_until() {
|
||||
local target now next
|
||||
target="$1"
|
||||
now=$(date +%s)
|
||||
next=$(date -d "today ${target}" +%s 2>/dev/null || echo 0)
|
||||
if [ "$next" -le "$now" ]; then
|
||||
next=$(date -d "tomorrow ${target}" +%s)
|
||||
fi
|
||||
echo $(( next - now ))
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "once" ]; then
|
||||
take_backup
|
||||
exit $?
|
||||
fi
|
||||
|
||||
log "backup sidecar started; schedule ${BACKUP_AT} daily, retention ${BACKUP_KEEP_DAYS} days"
|
||||
|
||||
# Baseline on first start: if there is no dump from the last 24 h, take one now
|
||||
# rather than leaving a fresh install unprotected until the first scheduled run.
|
||||
if ! find "$BACKUP_DIR" -maxdepth 1 -name "${DB}-*.dump" -mtime -1 | grep -q .; then
|
||||
log "no recent dump found; taking a baseline backup"
|
||||
take_backup || log "baseline backup failed; will retry on schedule"
|
||||
fi
|
||||
|
||||
while true; do
|
||||
wait_s="$(seconds_until "$BACKUP_AT")"
|
||||
log "next backup in ${wait_s}s (at ${BACKUP_AT})"
|
||||
sleep "$wait_s"
|
||||
take_backup || log "scheduled backup failed; continuing"
|
||||
done
|
||||
@@ -0,0 +1,307 @@
|
||||
# Production stack for a single box (Synology / QNAP / any Docker host).
|
||||
#
|
||||
# Data lives in bind mounts under ${DATA_ROOT} — NOT in named volumes — so it
|
||||
# survives `docker compose down`, image updates, project deletion in the NAS
|
||||
# UI, and a full reinstall, and so the NAS backup tools (Hyper Backup,
|
||||
# Snapshot Replication, HBS3) can actually see it.
|
||||
#
|
||||
# Nothing is published except ${WEB_PORT}. Postgres, Redis, Meilisearch and the
|
||||
# API are reachable only on the internal `webmail` network.
|
||||
#
|
||||
# Start: ./deploy/webmail.sh up
|
||||
# Manual: docker compose --env-file docker/.env.prod -f docker/docker-compose.prod.yml up -d
|
||||
|
||||
# Deliberately NOT "webmail": docker/docker-compose.yml (the dev service stack)
|
||||
# uses that project name, and two compose files sharing one project name fight
|
||||
# over each other's containers — running `make up` would stop production.
|
||||
name: webmail-prod
|
||||
|
||||
x-logging: &logging
|
||||
# A shared NAS must never have its volume filled by container logs.
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "3" }
|
||||
|
||||
x-backend-image: &backend-image
|
||||
image: ${REGISTRY_PREFIX:-webmail}/backend:${IMAGE_TAG:-local}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.backend
|
||||
args:
|
||||
APP_VERSION: ${IMAGE_TAG:-local}
|
||||
GIT_SHA: ${GIT_SHA:-unknown}
|
||||
|
||||
x-backend-env: &backend-env
|
||||
APP_DOMAIN: ${APP_DOMAIN}
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-webmail}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-webmail}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
MASTER_KEY: ${MASTER_KEY}
|
||||
BLOB_ROOT: /data/blobs
|
||||
SEARCH_BACKEND: ${SEARCH_BACKEND:-pg}
|
||||
MEILI_URL: ${MEILI_URL:-http://meilisearch:7700}
|
||||
MEILI_KEY: ${MEILI_MASTER_KEY:-}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
UNDO_SECONDS: ${UNDO_SECONDS:-10}
|
||||
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
|
||||
MAX_ATTACHMENT_MB: ${MAX_ATTACHMENT_MB:-25}
|
||||
TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30}
|
||||
SENDER_AVATARS_ENABLED: ${SENDER_AVATARS_ENABLED:-true}
|
||||
GOOGLE_OAUTH_ENABLED: ${GOOGLE_OAUTH_ENABLED:-false}
|
||||
OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:-}
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:-}
|
||||
OAUTH_MS_CLIENT_ID: ${OAUTH_MS_CLIENT_ID:-}
|
||||
OAUTH_MS_CLIENT_SECRET: ${OAUTH_MS_CLIENT_SECRET:-}
|
||||
# Worker sizing — see docker/.env.prod.example for what these cost in RAM.
|
||||
INTERACTIVE_CONCURRENCY: ${INTERACTIVE_CONCURRENCY:-8}
|
||||
BULK_CONCURRENCY: ${BULK_CONCURRENCY:-1}
|
||||
PARSE_CONCURRENCY: ${PARSE_CONCURRENCY:-2}
|
||||
STREAM_PARSE_MB: ${STREAM_PARSE_MB:-8}
|
||||
PER_HOST_CONN_CAP: ${PER_HOST_CONN_CAP:-10}
|
||||
MAX_ACCOUNTS_PER_WORKER: ${MAX_ACCOUNTS_PER_WORKER:-400}
|
||||
HOT_BUDGET_PER_WORKER: ${HOT_BUDGET_PER_WORKER:-50}
|
||||
APP_VERSION: ${IMAGE_TAG:-local}
|
||||
TZ: ${TZ:-UTC}
|
||||
|
||||
services:
|
||||
# One-shot: make the bind-mounted host folders writable by the unprivileged
|
||||
# container users before anything tries to use them. Runs as root by design.
|
||||
init:
|
||||
image: alpine:3.20
|
||||
user: root
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
mkdir -p /mnt/blobs /mnt/backups /mnt/redis /mnt/meili
|
||||
chown -R 10001:10001 /mnt/blobs # backend runs as uid 10001
|
||||
chown -R 999:999 /mnt/backups # pg_dump runs as postgres (999)
|
||||
chown -R 999:999 /mnt/redis # redis runs as uid 999
|
||||
chmod 700 /mnt/backups
|
||||
echo "[init] data directories ready"
|
||||
volumes:
|
||||
- ${DATA_ROOT}/blobs:/mnt/blobs
|
||||
- ${DATA_ROOT}/backups:/mnt/backups
|
||||
- ${DATA_ROOT}/redis:/mnt/redis
|
||||
- ${DATA_ROOT}/meili:/mnt/meili
|
||||
restart: "no"
|
||||
logging: *logging
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-webmail}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-webmail}
|
||||
# Keeps the data directory one level down, so the bind mount can hold lost+found
|
||||
# and NAS metadata files without initdb refusing to start.
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
TZ: ${TZ:-UTC}
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- shared_buffers=${PG_SHARED_BUFFERS:-256MB}
|
||||
- -c
|
||||
- max_connections=${PG_MAX_CONNECTIONS:-100}
|
||||
- -c
|
||||
- work_mem=${PG_WORK_MEM:-8MB}
|
||||
- -c
|
||||
- maintenance_work_mem=${PG_MAINTENANCE_WORK_MEM:-128MB}
|
||||
- -c
|
||||
- effective_cache_size=${PG_EFFECTIVE_CACHE_SIZE:-768MB}
|
||||
# Full page writes stay ON: NAS filesystems (btrfs/ext4) are not 8k-atomic.
|
||||
volumes:
|
||||
- ${DATA_ROOT}/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-webmail} -d ${POSTGRES_DB:-webmail}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
mem_limit: ${PG_MEM_LIMIT:-768m}
|
||||
stop_grace_period: 60s
|
||||
logging: *logging
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
# Leases and the arq queues live here. AOF everysec means a NAS power cut
|
||||
# costs at most a second of queue state instead of the whole queue.
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --appendfsync
|
||||
- everysec
|
||||
- --maxmemory
|
||||
- ${REDIS_MAXMEMORY:-96mb}
|
||||
- --maxmemory-policy
|
||||
- noeviction
|
||||
volumes:
|
||||
- ${DATA_ROOT}/redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
depends_on:
|
||||
init: { condition: service_completed_successfully }
|
||||
restart: unless-stopped
|
||||
mem_limit: ${REDIS_MEM_LIMIT:-160m}
|
||||
logging: *logging
|
||||
|
||||
# Opt-in: `./deploy/webmail.sh up --meili` (or COMPOSE_PROFILES=meili).
|
||||
# Costs ~700MB-1GB RSS; with SEARCH_BACKEND=pg the stack runs without it.
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.8
|
||||
profiles: ["meili"]
|
||||
environment:
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-}
|
||||
MEILI_ENV: production
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
TZ: ${TZ:-UTC}
|
||||
volumes:
|
||||
- ${DATA_ROOT}/meili:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:7700/health | grep -q available"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
depends_on:
|
||||
init: { condition: service_completed_successfully }
|
||||
restart: unless-stopped
|
||||
mem_limit: ${MEILI_MEM_LIMIT:-1g}
|
||||
logging: *logging
|
||||
|
||||
# One-shot schema upgrade: core Alembic head + every plugin's own branch.
|
||||
# Everything else waits for this to exit 0, so a failed migration stops the
|
||||
# roll instead of starting code against an old schema.
|
||||
migrate:
|
||||
<<: *backend-image
|
||||
command: ["migrate"]
|
||||
environment:
|
||||
<<: *backend-env
|
||||
ENABLED_PLUGINS: ${ENABLED_PLUGINS:-calendar contacts labels filters}
|
||||
volumes:
|
||||
- ${DATA_ROOT}/blobs:/data/blobs
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
init: { condition: service_completed_successfully }
|
||||
restart: "no"
|
||||
logging: *logging
|
||||
|
||||
api:
|
||||
<<: *backend-image
|
||||
command: ["api"]
|
||||
environment: *backend-env
|
||||
volumes:
|
||||
- ${DATA_ROOT}/blobs:/data/blobs
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/healthz', timeout=5).status==200 else 1)\""]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
migrate: { condition: service_completed_successfully }
|
||||
restart: unless-stopped
|
||||
mem_limit: ${API_MEM_LIMIT:-512m}
|
||||
cpus: ${API_CPUS:-1.0}
|
||||
logging: *logging
|
||||
|
||||
worker-sync:
|
||||
<<: *backend-image
|
||||
command: ["worker-sync"]
|
||||
environment: *backend-env
|
||||
volumes:
|
||||
- ${DATA_ROOT}/blobs:/data/blobs
|
||||
# Persistent IMAP IDLE connections; the default 1024 is fine for a personal
|
||||
# install but this costs nothing and matches the sync-engine v2 sizing.
|
||||
ulimits:
|
||||
nofile: { soft: 8192, hard: 8192 }
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
migrate: { condition: service_completed_successfully }
|
||||
restart: unless-stopped
|
||||
mem_limit: ${WORKER_SYNC_MEM_LIMIT:-512m}
|
||||
cpus: ${WORKER_CPUS:-1.5}
|
||||
# Leases are released on SIGTERM (workers/run.py targets <10 s); give it room.
|
||||
stop_grace_period: 30s
|
||||
logging: *logging
|
||||
|
||||
worker-jobs:
|
||||
<<: *backend-image
|
||||
command: ["worker-jobs"]
|
||||
environment: *backend-env
|
||||
volumes:
|
||||
- ${DATA_ROOT}/blobs:/data/blobs
|
||||
ulimits:
|
||||
nofile: { soft: 8192, hard: 8192 }
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
redis: { condition: service_healthy }
|
||||
migrate: { condition: service_completed_successfully }
|
||||
restart: unless-stopped
|
||||
mem_limit: ${WORKER_JOBS_MEM_LIMIT:-640m}
|
||||
cpus: ${WORKER_CPUS:-1.5}
|
||||
stop_grace_period: 30s
|
||||
logging: *logging
|
||||
|
||||
web:
|
||||
image: ${REGISTRY_PREFIX:-webmail}/web:${IMAGE_TAG:-local}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.frontend
|
||||
args:
|
||||
APP_VERSION: ${IMAGE_TAG:-local}
|
||||
GIT_SHA: ${GIT_SHA:-unknown}
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
# The ONLY published port. Bind to a specific NAS interface by setting
|
||||
# WEB_BIND (e.g. 192.168.1.185) to keep it off other networks.
|
||||
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-8088}:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz | grep -q ok"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
depends_on:
|
||||
api: { condition: service_started }
|
||||
restart: unless-stopped
|
||||
mem_limit: ${WEB_MEM_LIMIT:-96m}
|
||||
logging: *logging
|
||||
|
||||
# Nightly logical backup of Postgres into ${DATA_ROOT}/backups, pruned to
|
||||
# BACKUP_KEEP_DAYS. Point Hyper Backup / HBS3 at ${DATA_ROOT} and this plus the
|
||||
# blob store is the whole recoverable state (the other half is MASTER_KEY —
|
||||
# see docs/nas-install.md §"The three things that matter").
|
||||
backup:
|
||||
image: postgres:16
|
||||
user: "999:999"
|
||||
entrypoint: ["/bin/bash", "/usr/local/bin/backup.sh"]
|
||||
environment:
|
||||
PGHOST: postgres
|
||||
PGUSER: ${POSTGRES_USER:-webmail}
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||
PGDATABASE: ${POSTGRES_DB:-webmail}
|
||||
BACKUP_DIR: /backups
|
||||
BACKUP_AT: ${BACKUP_AT:-03:30}
|
||||
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
|
||||
TZ: ${TZ:-UTC}
|
||||
volumes:
|
||||
- ${DATA_ROOT}/backups:/backups
|
||||
- ./backup.sh:/usr/local/bin/backup.sh:ro
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
init: { condition: service_completed_successfully }
|
||||
restart: unless-stopped
|
||||
mem_limit: ${BACKUP_MEM_LIMIT:-256m}
|
||||
logging: *logging
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: webmail-prod
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,104 @@
|
||||
# Webmail front door: static SPA + reverse proxy to the API container.
|
||||
#
|
||||
# This file is dropped into /etc/nginx/conf.d/, i.e. it is already inside the
|
||||
# `http` block — hence the bare `map` below.
|
||||
|
||||
# Preserve the original scheme when something else terminates TLS in front of us
|
||||
# (Synology's Login Portal reverse proxy, QNAP's, Caddy, Cloudflare Tunnel...).
|
||||
# The API only emits Secure/__Host- cookies when it believes the request was
|
||||
# https, so losing this header silently downgrades session cookies.
|
||||
map $http_x_forwarded_proto $fwd_proto {
|
||||
default $scheme;
|
||||
https https;
|
||||
http http;
|
||||
}
|
||||
|
||||
# Connection header for the WebSocket upgrade dance on /api/ws.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
upstream webmail_api {
|
||||
server api:8000;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Attachment uploads. Keep this comfortably above MAX_ATTACHMENT_MB (default
|
||||
# 25) to leave room for multipart/base64 overhead.
|
||||
client_max_body_size 64m;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# The NAS is doing other jobs too — don't buffer big uploads to disk twice.
|
||||
proxy_request_buffering off;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json
|
||||
image/svg+xml application/manifest+json;
|
||||
|
||||
# Clickjacking / sniffing basics. The app's own CSP for mail HTML is applied
|
||||
# server-side in app/indexer/sanitize.py; this is only the shell document.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
|
||||
# ---- API ----------------------------------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://webmail_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $fwd_proto;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# WebSocket (/api/ws) and any long-poll: no upgrade means no live mail.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Live updates hold the connection open; don't cut them at 60 s.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ---- static -------------------------------------------------------------
|
||||
# Vite emits content-hashed filenames into /assets, so they are immutable.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# SPA fallback: every non-file route renders index.html (react-router).
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# index.html itself must never be cached, or a deploy won't be picked up.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, must-revalidate";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user