chore(supersync): harden compose for 4GB VPS production

Right-size container limits to fit a 4GB / 2-vCore VPS host without
OOM risk, and apply minimal best-practice hardening informed by
multi-agent review.

Container memory budget (sum 2816 MB, leaves ~1.2 GB host headroom):
- postgres: 1g → 1.5g (was at 74% under 199 WSS connections)
- supersync: 1g → 768m (uses 143 MiB steady state)
- dozzle: add 64m cap (was unbounded)
- uptime-kuma: add 192m cap (was unbounded)
- caddy: unchanged at 256m

Postgres tuning (1.5g cap, shm_size 256m to allow shared_buffers > 64m):
- shared_buffers=384MB, effective_cache_size=1GB
- work_mem=4MB, maintenance_work_mem=128MB
- max_connections=40 (Prisma pool is single-digit; 40 leaves headroom
  for migrations + psql; 100 was an OOM landmine vs 1.5g cap)
- random_page_cost=1.1 (SSD)
- idle_in_transaction_session_timeout=300000 (5 min — catches leaked
  Prisma idle-in-tx but stays well above migrate-deploy.sh
  STEP_TIMEOUT=1800 for CONCURRENTLY index recovery)
- wal_compression=on, huge_pages=off

Other hardening:
- Standardize restart policy to unless-stopped across all services
- Add caddy healthcheck via admin API at localhost:2019; Caddyfile
  comment warns future edits not to disable admin
- Add ulimits.nofile=65535 to supersync (Alpine soft default 1024 is
  tight for 200+ WSS + reconnect bursts)
- Pin networks.internal.name=super-sync-server_internal so the
  monitoring overlay can attach via external: true regardless of
  COMPOSE_PROJECT_NAME
- Attach dozzle + uptime-kuma to internal network so Kuma can probe
  http://supersync:1900/health by service name

Notes for future maintenance:
- oom_score_adj was tried and removed: Compose v2 silently ignores it
  (verified on host via /proc/<pid>/oom_score_adj returning 0).
  Would need a systemd post-up hack to apply — not worth it.
- The reconnect storm that motivated this work is fixed separately
  on master in b404bf8a3.
This commit is contained in:
johannesjo 2026-05-16 12:24:17 +02:00
parent 1661579aed
commit 25d637e917
3 changed files with 70 additions and 5 deletions

View file

@ -1,3 +1,8 @@
# NOTE: docker-compose.yml healthcheck probes the Caddy admin API at
# localhost:2019 (enabled by default in Caddy 2.x). Do NOT add a global
# `{ admin off }` block or rebind admin without also updating the healthcheck,
# or Caddy will restart-loop.
{$DOMAIN} {
reverse_proxy supersync:1900 {
# Allow up to 85 seconds for long-running operations

View file

@ -1,5 +1,10 @@
# Monitoring stack for SuperSync Server
#
# Attaches to the external `super-sync-server_internal` network created by
# the base docker-compose.yml — the base stack must be brought up first
# (or merged into the same `up` invocation as below). Running this file
# standalone will fail with "network not found".
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
#
@ -23,6 +28,9 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- DOZZLE_NO_ANALYTICS=true
networks:
- internal
mem_limit: 64m
# Uptime monitoring + alerts (~120MB RAM)
# Provides health check monitoring and notifications
@ -34,6 +42,16 @@ services:
- '127.0.0.1:3001:3001'
volumes:
- uptime-kuma-data:/app/data
networks:
- internal
mem_limit: 192m
# Attach to the internal network defined in docker-compose.yml so Kuma can
# probe http://supersync:1900/health by service name (not just public URL).
networks:
internal:
external: true
name: super-sync-server_internal
volumes:
uptime-kuma-data:

View file

@ -18,7 +18,7 @@ services:
supersync:
image: ${SUPERSYNC_IMAGE:-ghcr.io/super-productivity/supersync:latest}
container_name: supersync-server
restart: always
restart: unless-stopped
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=1900
@ -65,7 +65,13 @@ services:
- no-new-privileges:true
cap_drop:
- ALL
mem_limit: 1g
# 200+ WSS clients each consume an fd; Alpine default soft limit (1024)
# would be tight under reconnect bursts.
ulimits:
nofile:
soft: 65535
hard: 65535
mem_limit: 768m
cpus: '1.0'
networks:
- internal
@ -74,11 +80,38 @@ services:
postgres:
image: postgres:16-alpine
container_name: supersync-postgres
restart: always
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER:-supersync}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB:-supersync}
# Tuned for the 1.5g mem_limit. shared_buffers needs shm_size > default 64m.
# max_connections=40 keeps the worst-case sort budget (max_conn × work_mem)
# well inside the 1.5g cap; Prisma's per-instance pool is single-digit so
# 40 is generous headroom for migrations + psql + a future second replica.
command:
- postgres
- -c
- shared_buffers=384MB
- -c
- effective_cache_size=1GB
- -c
- work_mem=4MB
- -c
- maintenance_work_mem=128MB
- -c
- max_connections=40
- -c
- random_page_cost=1.1
- -c
# 5 min — kill leaked Prisma idle-in-tx, but well above the
# migrate-deploy.sh 30-min STEP_TIMEOUT for CONCURRENTLY index builds.
- idle_in_transaction_session_timeout=300000
- -c
- wal_compression=on
- -c
- huge_pages=off
shm_size: 256m
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
@ -93,7 +126,7 @@ services:
start_period: 10s
security_opt:
- no-new-privileges:true
mem_limit: 1g
mem_limit: 1536m
networks:
internal:
aliases:
@ -104,7 +137,7 @@ services:
caddy:
image: caddy:2.11-alpine
container_name: supersync-caddy
restart: always
restart: unless-stopped
ports:
- '80:80'
- '443:443'
@ -114,6 +147,12 @@ services:
- caddy-config:/config
environment:
- DOMAIN=${DOMAIN}
healthcheck:
test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:2019/config/']
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- internal
cap_drop:
@ -133,4 +172,7 @@ volumes:
networks:
internal:
# Pinned name so docker-compose.monitoring.yml can attach via
# `external: true` regardless of the COMPOSE_PROJECT_NAME used.
name: super-sync-server_internal
driver: bridge