Deploy: drop cpus: limits and stop assuming docker compose exists

Two NAS realities, both hit on a real Synology box.

- "NanoCPUs" error part-way through the project build: `cpus:` maps to the
  NanoCPUs field, which DSM's Docker daemon rejects. Removed from all three
  services (and from .env.prod.example). mem_limit uses the much older Memory
  field and works, so memory caps remain the throttle. Noted in the header
  next to the other DSM constraints.
- `docker compose build` on DSM: "'compose' is not a docker command". DSM and
  QTS ship the standalone `docker-compose` binary with no plugin subcommand,
  so every one of the script's 21 compose invocations was wrong for the target
  platform. The script now detects which is present, and only passes `--wait`
  when it finds v2 — on v1 it falls back to the health_gate it already had,
  which is also now run after `up` on both paths.

That the build reached container creation before failing says their compose
parses the rest of the file (anchors, profiles, service_completed_successfully,
top-level name), so this was a daemon-level rejection, not a schema one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
This commit is contained in:
2026-09-14 20:09:32 +02:00
co-authored by Claude Opus 5
parent f3dcd938b1
commit 2f03bee45b
3 changed files with 43 additions and 10 deletions
+37 -5
View File
@@ -33,8 +33,33 @@ grn() { printf '\033[32m%s\033[0m\n' "$*"; }
ylw() { printf '\033[33m%s\033[0m\n' "$*"; } ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
die() { red "error: $*" >&2; exit 1; } die() { red "error: $*" >&2; exit 1; }
# Which compose is on this box. Synology DSM and QNAP QTS ship the standalone
# `docker-compose` binary and have no `docker compose` subcommand, so detect
# rather than assume. COMPOSE_WAIT is empty on v1, which has no `--wait`; the
# script's own health_gate covers that case.
COMPOSE_BIN=()
COMPOSE_WAIT=""
detect_compose() {
[ "${#COMPOSE_BIN[@]}" -gt 0 ] && return 0
if docker compose version >/dev/null 2>&1; then
COMPOSE_BIN=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_BIN=(docker-compose)
else
die "no compose found: neither 'docker compose' nor 'docker-compose' is available"
fi
local ver
ver="$("${COMPOSE_BIN[@]}" version 2>/dev/null | head -1)"
case "$ver" in
*v2.*|*" 2."*) COMPOSE_WAIT="--wait" ;;
*) COMPOSE_WAIT=""
ylw "compose v1 detected (${ver:-unknown}) — using the script's own health checks" ;;
esac
}
compose() { compose() {
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" detect_compose
"${COMPOSE_BIN[@]}" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
} }
# Read KEY=VALUE pairs the way compose's .env parser does, WITHOUT sourcing. # Read KEY=VALUE pairs the way compose's .env parser does, WITHOUT sourcing.
@@ -301,7 +326,10 @@ cmd_up() {
# the `init` service fixes ownership. This line must not be fatal, because # the `init` service fixes ownership. This line must not be fatal, because
# DATA_ROOT is typically root-owned while you may be running as yourself. # DATA_ROOT is typically root-owned while you may be running as yourself.
mkdir -p "${DATA_ROOT}"/{postgres,blobs,redis,backups,meili,state} 2>/dev/null || true mkdir -p "${DATA_ROOT}"/{postgres,blobs,redis,backups,meili,state} 2>/dev/null || true
compose "${profile[@]}" up -d "${build[@]}" --wait detect_compose
# shellcheck disable=SC2086
compose "${profile[@]}" up -d "${build[@]}" $COMPOSE_WAIT
health_gate || die "services did not come up healthy — check: ./deploy/webmail.sh logs"
record_version "${IMAGE_TAG:-local}" record_version "${IMAGE_TAG:-local}"
grn "up. open http://<this-host>:${WEB_PORT:-8088}/" grn "up. open http://<this-host>:${WEB_PORT:-8088}/"
} }
@@ -395,7 +423,8 @@ cmd_update() {
grn "updated to ${target}" grn "updated to ${target}"
else else
red "health gate failed — rolling back to ${previous}" red "health gate failed — rolling back to ${previous}"
IMAGE_TAG="$previous" compose up -d --wait || true # shellcheck disable=SC2086
IMAGE_TAG="$previous" compose up -d $COMPOSE_WAIT || true
die "update to ${target} failed; production is back on ${previous}" die "update to ${target} failed; production is back on ${previous}"
fi fi
} }
@@ -405,7 +434,8 @@ cmd_rollback() {
local target="${1:-$(cat "$(state_dir)/previous" 2>/dev/null || echo "")}" local target="${1:-$(cat "$(state_dir)/previous" 2>/dev/null || echo "")}"
[ -n "$target" ] || die "no previous version recorded; pass a tag: ./deploy/webmail.sh rollback v1.0.0" [ -n "$target" ] || die "no previous version recorded; pass a tag: ./deploy/webmail.sh rollback v1.0.0"
ylw "rolling back to ${target}" ylw "rolling back to ${target}"
IMAGE_TAG="$target" compose up -d --wait # shellcheck disable=SC2086
IMAGE_TAG="$target" compose up -d $COMPOSE_WAIT
if health_gate; then if health_gate; then
record_version "$target" record_version "$target"
set_env_value IMAGE_TAG "$target" set_env_value IMAGE_TAG "$target"
@@ -459,7 +489,9 @@ cmd_restore() {
|| die "restore failed; the database was left as it was" || die "restore failed; the database was left as it was"
echo "starting application containers" echo "starting application containers"
compose up -d --wait # shellcheck disable=SC2086
compose up -d $COMPOSE_WAIT
health_gate || ylw "services are still starting; check: ./deploy/webmail.sh status"
grn "restored. If the blob store was lost too, mail bodies will refill on next sync." grn "restored. If the blob store was lost too, mail bodies will refill on next sync."
} }
+4 -3
View File
@@ -21,6 +21,10 @@
# Start: ./deploy/webmail.sh up # Start: ./deploy/webmail.sh up
# Manual: docker compose up -d (reads .env from this directory) # Manual: docker compose up -d (reads .env from this directory)
# #
# Likewise, no `cpus:` limits: that maps to NanoCPUs, which DSM's Docker
# daemon rejects ("NanoCPUs can not be set"). mem_limit uses the much older
# Memory field and works fine, so memory caps are the throttle here.
#
# Interpolation here must stay on the oldest, dumbest forms: ${VAR} and # Interpolation here must stay on the oldest, dumbest forms: ${VAR} and
# ${VAR:-default}. DSM's bundled Compose rejects `${VAR:?message}` with # ${VAR:-default}. DSM's bundled Compose rejects `${VAR:?message}` with
# "invalid interpolation format", and rejects `include:` outright. Required # "invalid interpolation format", and rejects `include:` outright. Required
@@ -224,7 +228,6 @@ services:
migrate: { condition: service_completed_successfully } migrate: { condition: service_completed_successfully }
restart: unless-stopped restart: unless-stopped
mem_limit: ${API_MEM_LIMIT:-512m} mem_limit: ${API_MEM_LIMIT:-512m}
cpus: ${API_CPUS:-1.0}
logging: *logging logging: *logging
worker-sync: worker-sync:
@@ -243,7 +246,6 @@ services:
migrate: { condition: service_completed_successfully } migrate: { condition: service_completed_successfully }
restart: unless-stopped restart: unless-stopped
mem_limit: ${WORKER_SYNC_MEM_LIMIT:-512m} 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. # Leases are released on SIGTERM (workers/run.py targets <10 s); give it room.
stop_grace_period: 30s stop_grace_period: 30s
logging: *logging logging: *logging
@@ -262,7 +264,6 @@ services:
migrate: { condition: service_completed_successfully } migrate: { condition: service_completed_successfully }
restart: unless-stopped restart: unless-stopped
mem_limit: ${WORKER_JOBS_MEM_LIMIT:-640m} mem_limit: ${WORKER_JOBS_MEM_LIMIT:-640m}
cpus: ${WORKER_CPUS:-1.5}
stop_grace_period: 30s stop_grace_period: 30s
logging: *logging logging: *logging
+2 -2
View File
@@ -91,6 +91,8 @@ STREAM_PARSE_MB=8
PER_HOST_CONN_CAP=10 PER_HOST_CONN_CAP=10
HOT_BUDGET_PER_WORKER=50 HOT_BUDGET_PER_WORKER=50
# Container caps. Memory only: CPU limits (`cpus:`) are deliberately absent
# because Synology's Docker daemon rejects them (NanoCPUs).
# Container caps. Total without Meilisearch: ~2.2 GB of *limits*; real steady # 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. # usage for one or two mail accounts is around 700 MB-1 GB.
PG_MEM_LIMIT=768m PG_MEM_LIMIT=768m
@@ -102,10 +104,8 @@ PG_EFFECTIVE_CACHE_SIZE=768MB
REDIS_MEM_LIMIT=160m REDIS_MEM_LIMIT=160m
REDIS_MAXMEMORY=96mb REDIS_MAXMEMORY=96mb
API_MEM_LIMIT=512m API_MEM_LIMIT=512m
API_CPUS=1.0
WORKER_SYNC_MEM_LIMIT=512m WORKER_SYNC_MEM_LIMIT=512m
WORKER_JOBS_MEM_LIMIT=640m WORKER_JOBS_MEM_LIMIT=640m
WORKER_CPUS=1.5
WEB_MEM_LIMIT=96m WEB_MEM_LIMIT=96m
BACKUP_MEM_LIMIT=256m BACKUP_MEM_LIMIT=256m
MEILI_MEM_LIMIT=1g MEILI_MEM_LIMIT=1g